fireblocks_config/
config.rs

1#[cfg(feature = "gpg")]
2use gpgme::{Context, Protocol};
3use {
4    crate::{Error, OutputFormat, Result},
5    config::{Config, File, FileFormat},
6    serde::Deserialize,
7    std::{
8        collections::HashMap,
9        fs,
10        path::{Path, PathBuf},
11        str::FromStr,
12        time::Duration,
13    },
14};
15
16pub(crate) fn expand_tilde(path: &str) -> PathBuf {
17    if path.starts_with('~') {
18        match dirs::home_dir() {
19            Some(mut home) => {
20                home.push(&path[2..]);
21                home
22            }
23            None => PathBuf::from(path),
24        }
25    } else {
26        PathBuf::from(path)
27    }
28}
29
30#[derive(Clone, Debug, Default, Deserialize)]
31pub struct DisplayConfig {
32    pub output: OutputFormat,
33}
34
35// Serde deserializer wrapper for parse_duration
36fn deserialize_duration<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
37where
38    D: serde::Deserializer<'de>,
39{
40    let s = String::deserialize(deserializer)?;
41    let seconds = u64::from_str(&s)
42        .map_err(|_| serde::de::Error::custom(format!("Invalid duration: {s}")))?;
43    Ok(Duration::from_secs(seconds))
44}
45
46pub(crate) fn default_poll_timeout() -> Duration {
47    Duration::from_secs(180)
48}
49
50pub(crate) fn default_poll_interval() -> Duration {
51    Duration::from_secs(5)
52}
53
54pub(crate) fn default_broadcast() -> bool {
55    false
56}
57
58#[derive(Clone, Debug, Deserialize)]
59pub struct Signer {
60    #[serde(
61        default = "default_poll_timeout",
62        deserialize_with = "deserialize_duration"
63    )]
64    pub poll_timeout: Duration,
65    #[serde(
66        default = "default_poll_interval",
67        deserialize_with = "deserialize_duration"
68    )]
69    pub poll_interval: Duration,
70    /// The vault id
71    pub vault: String,
72    /// If true, Fireblocks will broadcast the transaction
73    #[serde(default = "default_broadcast")]
74    pub broadcast: bool,
75}
76
77impl Default for Signer {
78    fn default() -> Self {
79        Self {
80            poll_timeout: default_poll_timeout(),
81            poll_interval: default_poll_interval(),
82            vault: String::new(),
83            broadcast: default_broadcast(),
84        }
85    }
86}
87
88#[derive(Clone, Debug, Default, Deserialize)]
89pub struct FireblocksConfig {
90    pub api_key: String,
91    pub url: String,
92    pub secret_path: Option<PathBuf>,
93    pub secret: Option<String>,
94    #[serde(rename = "display", default)]
95    pub display_config: DisplayConfig,
96    pub signer: Signer,
97    /// Arbitrary extra configuration values
98    #[serde(default)]
99    pub extra: HashMap<String, serde_json::Value>,
100    /// Enable debug mode
101    #[serde(default)]
102    pub debug: bool,
103
104    #[serde(default)]
105    pub mainnet: bool,
106}
107
108impl FireblocksConfig {
109    /// Get an extra configuration value as any deserializable type
110    pub fn get_extra<T, K>(&self, key: K) -> Result<T>
111    where
112        T: serde::de::DeserializeOwned,
113        K: AsRef<str>,
114    {
115        let key_str = key.as_ref();
116        let value = self.extra.get(key_str).ok_or_else(|| Error::NotPresent {
117            key: key_str.to_string(),
118        })?;
119
120        serde_json::from_value(value.clone()).map_err(|e| {
121            Error::ConfigParseError(config::ConfigError::Message(format!(
122                "Failed to deserialize key '{key_str}': {e}"
123            )))
124        })
125    }
126
127    /// Get an extra configuration value as a Duration from seconds
128    ///
129    /// This function retrieves a numeric value from the extra configuration
130    /// and converts it to a `std::time::Duration` using
131    /// `Duration::from_secs()`.
132    ///
133    /// # Arguments
134    ///
135    /// * `key` - The configuration key to look up (can be `&str`, `String`,
136    ///   etc.)
137    ///
138    /// # Returns
139    ///
140    /// * `Ok(Duration)` - The duration value if the key exists and can be
141    ///   parsed as u64
142    /// * `Err(Error::NotPresent)` - If the key doesn't exist in the
143    ///   configuration
144    /// * `Err(Error::ConfigParseError)` - If the value cannot be deserialized
145    ///   as u64
146    ///
147    /// # Examples
148    ///
149    /// ```rust,no_run
150    /// use {fireblocks_config::FireblocksConfig, std::time::Duration};
151    ///
152    /// let config = FireblocksConfig::new("config.toml", &[])?;
153    ///
154    /// // Get timeout as Duration (assuming config has: timeout = 30)
155    /// let timeout: Duration = config.get_extra_duration("timeout")?;
156    /// assert_eq!(timeout, Duration::from_secs(30));
157    /// # Ok::<(), Box<dyn std::error::Error>>(())
158    /// ```
159    pub fn get_extra_duration<K>(&self, key: K) -> Result<Duration>
160    where
161        K: AsRef<str>,
162    {
163        let seconds: u64 = self.get_extra(key)?;
164        Ok(Duration::from_secs(seconds))
165    }
166
167    /// Check if an extra configuration key exists
168    pub fn has_extra<K>(&self, key: K) -> bool
169    where
170        K: AsRef<str>,
171    {
172        self.extra.contains_key(key.as_ref())
173    }
174
175    pub fn get_key(&self) -> Result<Vec<u8>> {
176        // Try secret_key first (simpler case)
177        if let Some(ref key) = self.secret {
178            return Ok(key.clone().into_bytes());
179        }
180
181        // Then try secret_path
182        let path = self.secret_path.as_ref().ok_or(Error::MissingSecret)?;
183        let expanded_path = if path.starts_with("~") {
184            expand_tilde(&path.to_string_lossy())
185        } else {
186            path.clone()
187        };
188
189        #[cfg(feature = "gpg")]
190        if expanded_path
191            .extension()
192            .is_some_and(|ext| ext.eq_ignore_ascii_case("gpg"))
193        {
194            return self.decrypt_gpg_file(&expanded_path);
195        }
196
197        // Regular file read
198        fs::read(&expanded_path).map_err(|e| Error::IOError {
199            source: e,
200            path: expanded_path.to_string_lossy().to_string(),
201        })
202    }
203
204    #[cfg(feature = "gpg")]
205    fn decrypt_gpg_file(&self, path: &Path) -> Result<Vec<u8>> {
206        let mut ctx = Context::from_protocol(Protocol::OpenPgp)?;
207
208        let mut input = fs::File::open(path).map_err(|e| Error::IOError {
209            source: e,
210            path: path.to_string_lossy().to_string(),
211        })?;
212
213        let mut output = Vec::new();
214        ctx.decrypt(&mut input, &mut output)?;
215
216        Ok(output)
217    }
218}
219impl FireblocksConfig {
220    pub fn new<P: AsRef<Path>>(cfg: P, cfg_overrides: &[P]) -> Result<Self> {
221        let cfg_path = cfg.as_ref();
222        log::debug!("using config {}", cfg_path.display());
223
224        let mut config_builder =
225            Config::builder().add_source(File::new(&cfg_path.to_string_lossy(), FileFormat::Toml));
226
227        // Add all override files in order
228        for override_path in cfg_overrides {
229            let path = override_path.as_ref();
230            log::debug!("adding config override: {}", path.display());
231            config_builder = config_builder
232                .add_source(File::new(&path.to_string_lossy(), FileFormat::Toml).required(true));
233        }
234
235        // Environment variables still take highest precedence
236        config_builder = config_builder
237            .add_source(config::Environment::with_prefix("FIREBLOCKS").try_parsing(true));
238
239        let conf: Self = config_builder.build()?.try_deserialize()?;
240        log::trace!("loaded config {conf:#?}");
241        Ok(conf)
242    }
243
244    pub fn with_overrides<P: AsRef<Path>>(
245        cfg: P,
246        overrides: impl IntoIterator<Item = P>,
247    ) -> Result<Self> {
248        let override_vec: Vec<P> = overrides.into_iter().collect();
249        Self::new(cfg, &override_vec)
250    }
251
252    /// Load configuration from XDG config directory
253    /// (~/.config/fireblocks/default.toml)
254    pub fn init() -> Result<Self> {
255        Self::init_with_profiles::<&str>(&[])
256    }
257
258    /// Load configuration from XDG config directory with additional profile
259    /// overrides
260    ///
261    /// Loads ~/.config/fireblocks/default.toml as base config, then applies
262    /// each profile from ~/.config/fireblocks/{profile}.toml in order.
263    ///
264    /// # Example
265    /// ```rust,no_run
266    /// use fireblocks_config::FireblocksConfig;
267    ///
268    /// // Load default config only
269    /// let config = FireblocksConfig::init()?;
270    ///
271    /// // Load default + production profile
272    /// let config = FireblocksConfig::init_with_profiles(&["production"])?;
273    ///
274    /// // Load default + staging + production (layered)
275    /// let config = FireblocksConfig::init_with_profiles(&["staging", "production"])?;
276    /// # Ok::<(), Box<dyn std::error::Error>>(())
277    /// ```
278    pub fn init_with_profiles<S: AsRef<str>>(profiles: &[S]) -> Result<Self> {
279        let config_dir = dirs::config_dir().ok_or(Error::XdgConfigNotFound)?;
280        let fireblocks_dir = config_dir.join("fireblocks");
281        let default_config = fireblocks_dir.join("default.toml");
282
283        if !default_config.exists() {
284            return Err(Error::ConfigNotFound(
285                default_config.to_string_lossy().to_string(),
286            ));
287        }
288
289        log::debug!("loading default config: {}", default_config.display());
290
291        let mut profile_configs = Vec::new();
292        for profile in profiles {
293            let profile_file = format!("{}.toml", profile.as_ref());
294            let profile_config = fireblocks_dir.join(&profile_file);
295            if profile_config.exists() {
296                log::debug!("adding profile config: {}", profile_config.display());
297                profile_configs.push(profile_config);
298            } else {
299                return Err(Error::ProfileConfigNotFound(profile_file));
300            }
301        }
302
303        Self::new(default_config, &profile_configs)
304    }
305}