#[cfg(feature = "gpg")]
use gpgme::{Context, Protocol};
use {
crate::{Error, OutputFormat, Result},
config::{Config, File, FileFormat},
serde::Deserialize,
std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
str::FromStr,
time::Duration,
},
};
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
if path.starts_with('~') {
match dirs::home_dir() {
Some(mut home) => {
home.push(&path[2..]);
home
}
None => PathBuf::from(path),
}
} else {
PathBuf::from(path)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct DisplayConfig {
pub output: OutputFormat,
}
fn deserialize_duration<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let seconds = u64::from_str(&s)
.map_err(|_| serde::de::Error::custom(format!("Invalid duration: {s}")))?;
Ok(Duration::from_secs(seconds))
}
pub(crate) fn default_poll_timeout() -> Duration {
Duration::from_secs(180)
}
pub(crate) fn default_poll_interval() -> Duration {
Duration::from_secs(5)
}
pub(crate) fn default_broadcast() -> bool {
false
}
#[derive(Clone, Debug, Deserialize)]
pub struct Signer {
#[serde(
default = "default_poll_timeout",
deserialize_with = "deserialize_duration"
)]
pub poll_timeout: Duration,
#[serde(
default = "default_poll_interval",
deserialize_with = "deserialize_duration"
)]
pub poll_interval: Duration,
pub vault: String,
#[serde(default = "default_broadcast")]
pub broadcast: bool,
}
impl Default for Signer {
fn default() -> Self {
Self {
poll_timeout: default_poll_timeout(),
poll_interval: default_poll_interval(),
vault: String::new(),
broadcast: default_broadcast(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct FireblocksConfig {
pub api_key: String,
pub url: String,
pub secret_path: Option<PathBuf>,
pub secret: Option<String>,
#[serde(rename = "display", default)]
pub display_config: DisplayConfig,
pub signer: Signer,
#[serde(default)]
pub extra: HashMap<String, serde_json::Value>,
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub mainnet: bool,
}
impl FireblocksConfig {
pub fn get_extra<T, K>(&self, key: K) -> Result<T>
where
T: serde::de::DeserializeOwned,
K: AsRef<str>,
{
let key_str = key.as_ref();
let value = self.extra.get(key_str).ok_or_else(|| Error::NotPresent {
key: key_str.to_string(),
})?;
serde_json::from_value(value.clone()).map_err(|e| {
Error::ConfigParseError(config::ConfigError::Message(format!(
"Failed to deserialize key '{key_str}': {e}"
)))
})
}
pub fn get_extra_duration<K>(&self, key: K) -> Result<Duration>
where
K: AsRef<str>,
{
let seconds: u64 = self.get_extra(key)?;
Ok(Duration::from_secs(seconds))
}
pub fn has_extra<K>(&self, key: K) -> bool
where
K: AsRef<str>,
{
self.extra.contains_key(key.as_ref())
}
pub fn get_key(&self) -> Result<Vec<u8>> {
if let Some(ref key) = self.secret {
return Ok(key.clone().into_bytes());
}
let path = self.secret_path.as_ref().ok_or(Error::MissingSecret)?;
let expanded_path = if path.starts_with("~") {
expand_tilde(&path.to_string_lossy())
} else {
path.clone()
};
#[cfg(feature = "gpg")]
if expanded_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gpg"))
{
return self.decrypt_gpg_file(&expanded_path);
}
fs::read(&expanded_path).map_err(|e| Error::IOError {
source: e,
path: expanded_path.to_string_lossy().to_string(),
})
}
#[cfg(feature = "gpg")]
fn decrypt_gpg_file(&self, path: &Path) -> Result<Vec<u8>> {
let mut ctx = Context::from_protocol(Protocol::OpenPgp)?;
let mut input = fs::File::open(path).map_err(|e| Error::IOError {
source: e,
path: path.to_string_lossy().to_string(),
})?;
let mut output = Vec::new();
ctx.decrypt(&mut input, &mut output)?;
Ok(output)
}
}
impl FireblocksConfig {
pub fn new<P: AsRef<Path>>(cfg: P, cfg_overrides: &[P]) -> Result<Self> {
let cfg_path = cfg.as_ref();
tracing::debug!("using config {}", cfg_path.display());
let mut config_builder =
Config::builder().add_source(File::new(&cfg_path.to_string_lossy(), FileFormat::Toml));
for override_path in cfg_overrides {
let path = override_path.as_ref();
tracing::debug!("adding config override: {}", path.display());
config_builder = config_builder
.add_source(File::new(&path.to_string_lossy(), FileFormat::Toml).required(true));
}
config_builder = config_builder
.add_source(config::Environment::with_prefix("FIREBLOCKS").try_parsing(true));
let conf: Self = config_builder.build()?.try_deserialize()?;
tracing::trace!("loaded config {conf:#?}");
Ok(conf)
}
pub fn with_overrides<P: AsRef<Path>>(
cfg: P,
overrides: impl IntoIterator<Item = P>,
) -> Result<Self> {
let override_vec: Vec<P> = overrides.into_iter().collect();
Self::new(cfg, &override_vec)
}
pub fn init() -> Result<Self> {
Self::init_with_profiles::<&str>(&[])
}
pub fn init_with_profiles<S: AsRef<str>>(profiles: &[S]) -> Result<Self> {
let config_dir = dirs::config_dir().ok_or(Error::XdgConfigNotFound)?;
let fireblocks_dir = config_dir.join("fireblocks");
let default_config = fireblocks_dir.join("default.toml");
if !default_config.exists() {
return Err(Error::ConfigNotFound(
default_config.to_string_lossy().to_string(),
));
}
tracing::debug!("loading default config: {}", default_config.display());
let mut profile_configs = Vec::new();
for profile in profiles {
let profile_file = format!("{}.toml", profile.as_ref());
let profile_config = fireblocks_dir.join(&profile_file);
if profile_config.exists() {
tracing::debug!("adding profile config: {}", profile_config.display());
profile_configs.push(profile_config);
} else {
return Err(Error::ProfileConfigNotFound(profile_file));
}
}
Self::new(default_config, &profile_configs)
}
}