use std::collections::HashMap;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{anyhow, Result};
use clap::ArgMatches;
use lazy_static::lazy_static;
use secrecy::SecretString;
use serde::{de, Deserialize, Serialize};
use xdg::BaseDirectories;
use crate::kbs2::backend::{Backend, RageLib};
use crate::kbs2::generator::Generator;
use crate::kbs2::util;
pub static CONFIG_BASENAME: &str = "config.toml";
pub static LEGACY_CONFIG_BASENAME: &str = "kbs2.conf";
pub static DEFAULT_KEY_BASENAME: &str = "key";
lazy_static! {
static ref XDG_DIRS: BaseDirectories = {
#[allow(clippy::expect_used)]
BaseDirectories::with_prefix(env!("CARGO_PKG_NAME"))
.expect("Fatal: XDG: couldn't determine reasonable base directories")
};
pub static ref DEFAULT_CONFIG_DIR: PathBuf = XDG_DIRS.get_config_home();
pub static ref DEFAULT_STORE_DIR: PathBuf = XDG_DIRS.get_data_home();
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Config {
#[serde(skip)]
pub config_dir: String,
#[serde(rename = "public-key")]
pub public_key: String,
#[serde(deserialize_with = "deserialize_with_tilde")]
pub keyfile: String,
#[serde(rename = "agent-autostart")]
#[serde(default = "default_as_true")]
pub agent_autostart: bool,
#[serde(default = "default_as_true")]
pub wrapped: bool,
#[serde(deserialize_with = "deserialize_with_tilde")]
pub store: String,
#[serde(default)]
pub pinentry: Pinentry,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "pre-hook")]
#[serde(default)]
pub pre_hook: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "post-hook")]
#[serde(default)]
pub post_hook: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "error-hook")]
#[serde(default)]
pub error_hook: Option<String>,
#[serde(default)]
#[serde(rename = "reentrant-hooks")]
pub reentrant_hooks: bool,
#[serde(default)]
pub generators: Vec<GeneratorConfig>,
#[serde(default)]
pub commands: CommandConfigs,
}
impl Config {
pub fn call_hook(&self, cmd: &str, args: &[&str]) -> Result<()> {
if self.reentrant_hooks || env::var("KBS2_HOOK").is_err() {
let success = Command::new(cmd)
.args(args)
.current_dir(Path::new(&self.store))
.env("KBS2_HOOK", "1")
.env("KBS2_CONFIG_DIR", &self.config_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.status()
.map(|s| s.success())
.map_err(|_| anyhow!("failed to run hook: {}", cmd))?;
if success {
Ok(())
} else {
Err(anyhow!("hook exited with an error code: {}", cmd))
}
} else {
util::warn("nested hook requested without reentrant-hooks; skipping");
Ok(())
}
}
pub fn generator(&self, name: &str) -> Option<&dyn Generator> {
for generator_config in self.generators.iter() {
let generator = generator_config.as_dyn();
if generator.name() == name {
return Some(generator);
}
}
None
}
pub fn with_matches<'a>(&'a self, matches: &'a ArgMatches) -> RuntimeConfig<'a> {
RuntimeConfig {
config: self,
matches,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Pinentry(String);
impl Default for Pinentry {
fn default() -> Self {
Self("pinentry".into())
}
}
impl AsRef<OsStr> for Pinentry {
fn as_ref(&self) -> &OsStr {
self.0.as_ref()
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum GeneratorConfig {
Command(ExternalGeneratorConfig),
Internal(InternalGeneratorConfig),
InternalLegacy(LegacyInternalGeneratorConfig),
}
impl GeneratorConfig {
fn as_dyn(&self) -> &dyn Generator {
match self {
GeneratorConfig::Command(g) => g as &dyn Generator,
GeneratorConfig::Internal(g) => g as &dyn Generator,
GeneratorConfig::InternalLegacy(g) => g as &dyn Generator,
}
}
}
impl Default for GeneratorConfig {
fn default() -> Self {
GeneratorConfig::Internal(Default::default())
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ExternalGeneratorConfig {
pub name: String,
pub command: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InternalGeneratorConfig {
pub name: String,
pub alphabets: Vec<String>,
pub length: usize,
}
impl Default for InternalGeneratorConfig {
fn default() -> Self {
InternalGeneratorConfig {
name: "default".into(),
alphabets: vec![
"abcdefghijklmnopqrstuvwxyz".into(),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ".into(),
"0123456789".into(),
"(){}[]-_+=".into(),
],
length: 16,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LegacyInternalGeneratorConfig {
pub name: String,
pub alphabet: String,
pub length: u32,
}
#[derive(Clone, Default, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CommandConfigs {
pub new: NewConfig,
pub pass: PassConfig,
pub edit: EditConfig,
pub rm: RmConfig,
pub ext: HashMap<String, HashMap<String, toml::Value>>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct NewConfig {
#[serde(rename = "default-username")]
pub default_username: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "pre-hook")]
pub pre_hook: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "post-hook")]
pub post_hook: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PassConfig {
#[serde(rename = "clipboard-duration")]
pub clipboard_duration: u64,
#[serde(rename = "clear-after")]
pub clear_after: bool,
#[serde(rename = "x11-clipboard")]
pub x11_clipboard: X11Clipboard,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "pre-hook")]
pub pre_hook: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "post-hook")]
pub post_hook: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "clear-hook")]
pub clear_hook: Option<String>,
}
#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum X11Clipboard {
Clipboard,
Primary,
}
impl Default for PassConfig {
fn default() -> Self {
PassConfig {
clipboard_duration: 10,
clear_after: true,
x11_clipboard: X11Clipboard::Clipboard,
pre_hook: None,
post_hook: None,
clear_hook: None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct EditConfig {
pub editor: Option<String>,
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "post-hook")]
pub post_hook: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct RmConfig {
#[serde(deserialize_with = "deserialize_optional_with_tilde")]
#[serde(rename = "post-hook")]
pub post_hook: Option<String>,
}
pub struct RuntimeConfig<'a> {
pub config: &'a Config,
pub matches: &'a ArgMatches,
}
impl<'a> RuntimeConfig<'a> {
pub fn generator(&self) -> Result<&dyn Generator> {
if let Some(generator) = self.matches.get_one::<String>("generator") {
self.config
.generator(generator)
.ok_or_else(|| anyhow!("no generator named {generator}"))
} else {
self.config
.generator("default")
.ok_or_else(|| anyhow!("missing default generator?"))
}
}
pub fn terse(&self) -> bool {
atty::isnt(atty::Stream::Stdin) || *self.matches.get_one::<bool>("terse").unwrap_or(&false)
}
}
#[doc(hidden)]
#[inline]
fn deserialize_with_tilde<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
where
D: de::Deserializer<'de>,
{
let unexpanded: &str = Deserialize::deserialize(deserializer)?;
Ok(shellexpand::tilde(unexpanded).into_owned())
}
#[doc(hidden)]
#[inline]
fn deserialize_optional_with_tilde<'de, D>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
D: de::Deserializer<'de>,
{
let unexpanded: Option<&str> = Deserialize::deserialize(deserializer)?;
match unexpanded {
Some(unexpanded) => Ok(Some(shellexpand::tilde(unexpanded).into_owned())),
None => Ok(None),
}
}
#[doc(hidden)]
#[inline]
fn default_as_true() -> bool {
true
}
pub fn initialize<P: AsRef<Path>>(
config_dir: P,
store_dir: P,
password: Option<SecretString>,
) -> Result<()> {
fs::create_dir_all(&config_dir)?;
let keyfile = config_dir.as_ref().join(DEFAULT_KEY_BASENAME);
let mut wrapped = false;
let public_key = if let Some(password) = password {
wrapped = true;
RageLib::create_wrapped_keypair(&keyfile, password)?
} else {
RageLib::create_keypair(&keyfile)?
};
log::debug!("public key: {}", public_key);
let serialized = {
let config_dir = config_dir
.as_ref()
.to_str()
.ok_or_else(|| anyhow!("unencodable config dir"))?
.into();
let store = store_dir
.as_ref()
.to_str()
.ok_or_else(|| anyhow!("unencodable store dir"))?
.into();
#[allow(clippy::redundant_field_names)]
toml::to_string(&Config {
config_dir: config_dir,
public_key: public_key,
keyfile: keyfile
.to_str()
.ok_or_else(|| anyhow!("unrepresentable keyfile path: {:?}", keyfile))?
.into(),
agent_autostart: true,
wrapped: wrapped,
store: store,
pinentry: Default::default(),
pre_hook: None,
post_hook: None,
error_hook: None,
reentrant_hooks: false,
generators: vec![GeneratorConfig::Internal(Default::default())],
commands: Default::default(),
})?
};
fs::write(config_dir.as_ref().join(CONFIG_BASENAME), serialized)?;
Ok(())
}
pub fn load<P: AsRef<Path>>(config_dir: P) -> Result<Config> {
let config_dir = config_dir.as_ref();
let config_path = config_dir.join(CONFIG_BASENAME);
let contents = if config_path.is_file() {
fs::read_to_string(config_path)?
} else {
util::warn(&format!(
"{} not found in config dir; trying {}",
CONFIG_BASENAME, LEGACY_CONFIG_BASENAME
));
util::warn("note: this behavior will be removed in a future stable release");
fs::read_to_string(config_dir.join(LEGACY_CONFIG_BASENAME))?
};
let mut config = Config {
config_dir: config_dir
.to_str()
.ok_or_else(|| anyhow!("unrepresentable config dir path: {:?}", config_dir))?
.into(),
..toml::from_str(&contents).map_err(|e| anyhow!("config loading error: {}", e))?
};
if config.generators.is_empty() {
config.generators.push(Default::default());
}
for gen in config.generators.iter() {
if matches!(gen, GeneratorConfig::InternalLegacy(_)) {
util::warn(&format!("loaded legacy generator: {}", gen.as_dyn().name()));
util::warn("note: this behavior will be removed in a future stable release");
}
}
Ok(config)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
fn dummy_config_unwrapped_key() -> Config {
Config {
config_dir: "/not/a/real/dir".into(),
public_key: "not a real public key".into(),
keyfile: "not a real private key file".into(),
agent_autostart: false,
wrapped: false,
store: "/tmp".into(),
pinentry: Default::default(),
pre_hook: Some("true".into()),
post_hook: Some("false".into()),
error_hook: Some("true".into()),
reentrant_hooks: false,
generators: vec![GeneratorConfig::Internal(Default::default())],
commands: CommandConfigs {
rm: RmConfig {
post_hook: Some("this-command-does-not-exist".into()),
},
..Default::default()
},
}
}
#[test]
fn test_find_default_config_dir() {
assert!(!DEFAULT_CONFIG_DIR.is_file());
}
#[test]
fn test_find_default_store_dir() {
assert!(!DEFAULT_STORE_DIR.is_file());
}
#[test]
fn test_initialize_unwrapped() {
{
let config_dir = tempdir().unwrap();
let store_dir = tempdir().unwrap();
assert!(initialize(&config_dir, &store_dir, None).is_ok());
let config_dir = config_dir.path();
assert!(config_dir.exists());
assert!(config_dir.is_dir());
assert!(config_dir.join(CONFIG_BASENAME).exists());
assert!(config_dir.join(CONFIG_BASENAME).is_file());
assert!(config_dir.join(DEFAULT_KEY_BASENAME).exists());
assert!(config_dir.join(DEFAULT_KEY_BASENAME).is_file());
let config = load(config_dir).unwrap();
assert!(!config.wrapped);
}
}
#[test]
fn test_initialize_wrapped() {
{
let config_dir = tempdir().unwrap();
let store_dir = tempdir().unwrap();
assert!(initialize(
&config_dir,
&store_dir,
Some(SecretString::new("badpassword".into()))
)
.is_ok());
let config_dir = config_dir.path();
assert!(config_dir.exists());
assert!(config_dir.is_dir());
assert!(config_dir.join(CONFIG_BASENAME).exists());
assert!(config_dir.join(CONFIG_BASENAME).is_file());
assert!(config_dir.join(DEFAULT_KEY_BASENAME).exists());
assert!(config_dir.join(DEFAULT_KEY_BASENAME).is_file());
let config = load(config_dir).unwrap();
assert!(config.wrapped);
}
}
#[test]
fn test_load() {
{
let config_dir = tempdir().unwrap();
let store_dir = tempdir().unwrap();
initialize(&config_dir, &store_dir, None).unwrap();
assert!(load(&config_dir).is_ok());
}
{
let config_dir = tempdir().unwrap();
let store_dir = tempdir().unwrap();
initialize(&config_dir, &store_dir, None).unwrap();
let config = load(&config_dir).unwrap();
assert_eq!(config_dir.path().to_str().unwrap(), config.config_dir);
assert_eq!(store_dir.path().to_str().unwrap(), config.store);
}
}
#[test]
fn test_call_hook() {
let config = dummy_config_unwrapped_key();
{
assert!(config
.call_hook(config.pre_hook.as_ref().unwrap(), &[])
.is_ok());
}
{
let err = config
.call_hook(config.commands.rm.post_hook.as_ref().unwrap(), &[])
.unwrap_err();
assert_eq!(
err.to_string(),
"failed to run hook: this-command-does-not-exist"
);
}
{
let err = config
.call_hook(config.post_hook.as_ref().unwrap(), &[])
.unwrap_err();
assert_eq!(err.to_string(), "hook exited with an error code: false");
}
{
assert!(config
.call_hook(config.error_hook.as_ref().unwrap(), &[])
.is_ok());
}
}
#[test]
fn test_get_generator() {
let config = dummy_config_unwrapped_key();
assert!(config.generator("default").is_some());
assert!(config.generator("nonexistent-generator").is_none());
}
}