fireblocks_config/
config.rs

1#[cfg(feature = "gpg")]
2use gpgme::{Context, Protocol};
3#[cfg(feature = "xdg")]
4use microxdg::XdgApp;
5use {
6    crate::{Error, OutputFormat, Result},
7    config::{Config, File, FileFormat},
8    serde::Deserialize,
9    std::{
10        fs,
11        path::{Path, PathBuf},
12        str::FromStr,
13        time::Duration,
14    },
15};
16
17fn expand_tilde(path: &str) -> PathBuf {
18    if path.starts_with('~') {
19        match dirs::home_dir() {
20            Some(mut home) => {
21                home.push(&path[2..]);
22                home
23            }
24            None => PathBuf::from(path),
25        }
26    } else {
27        PathBuf::from(path)
28    }
29}
30
31#[derive(Clone, Debug, Default, Deserialize)]
32pub struct DisplayConfig {
33    pub output: OutputFormat,
34}
35
36// Serde deserializer wrapper for parse_duration
37fn deserialize_duration<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
38where
39    D: serde::Deserializer<'de>,
40{
41    let s = String::deserialize(deserializer)?;
42    let seconds = u64::from_str(&s)
43        .map_err(|_| serde::de::Error::custom(format!("Invalid duration: {s}")))?;
44    Ok(Duration::from_secs(seconds))
45}
46
47fn default_poll_timeout() -> Duration {
48    Duration::from_secs(180)
49}
50
51fn default_poll_interval() -> Duration {
52    Duration::from_secs(5)
53}
54
55#[derive(Clone, Debug, Default, Deserialize)]
56pub struct Signer {
57    #[serde(
58        default = "default_poll_timeout",
59        deserialize_with = "deserialize_duration"
60    )]
61    pub poll_timeout: Duration,
62    #[serde(
63        default = "default_poll_interval",
64        deserialize_with = "deserialize_duration"
65    )]
66    pub poll_interval: Duration,
67    /// The vault id
68    pub vault: String,
69}
70
71#[derive(Clone, Debug, Default, Deserialize)]
72pub struct FireblocksConfig {
73    pub api_key: String,
74    pub url: String,
75    pub secret_path: Option<PathBuf>,
76    pub secret: Option<String>,
77    #[serde(rename = "display")]
78    pub display_config: DisplayConfig,
79    pub signer: Signer,
80}
81
82impl FireblocksConfig {
83    pub fn get_key(&self) -> Result<Vec<u8>> {
84        // Try secret_key first (simpler case)
85        if let Some(ref key) = self.secret {
86            return Ok(key.clone().into_bytes());
87        }
88
89        // Then try secret_path
90        let path = self.secret_path.as_ref().ok_or(Error::MissingSecret)?;
91        let expanded_path = if path.starts_with("~") {
92            expand_tilde(&path.to_string_lossy())
93        } else {
94            path.clone()
95        };
96
97        #[cfg(feature = "gpg")]
98        if expanded_path
99            .extension()
100            .is_some_and(|ext| ext.eq_ignore_ascii_case("gpg"))
101        {
102            return self.decrypt_gpg_file(&expanded_path);
103        }
104
105        // Regular file read
106        fs::read(&expanded_path).map_err(|e| Error::IOError {
107            source: e,
108            path: expanded_path.to_string_lossy().to_string(),
109        })
110    }
111
112    #[cfg(feature = "gpg")]
113    fn decrypt_gpg_file(&self, path: &Path) -> Result<Vec<u8>> {
114        let mut ctx = Context::from_protocol(Protocol::OpenPgp)?;
115
116        let mut input = fs::File::open(path).map_err(|e| Error::IOError {
117            source: e,
118            path: path.to_string_lossy().to_string(),
119        })?;
120
121        let mut output = Vec::new();
122        ctx.decrypt(&mut input, &mut output)?;
123
124        Ok(output)
125    }
126}
127impl FireblocksConfig {
128    pub fn new<P: AsRef<Path>>(cfg: P, cfg_overrides: &[P]) -> Result<Self> {
129        let cfg_path = cfg.as_ref();
130        tracing::debug!("using config {}", cfg_path.display());
131
132        let mut config_builder =
133            Config::builder().add_source(File::new(&cfg_path.to_string_lossy(), FileFormat::Toml));
134
135        // Add all override files in order
136        for override_path in cfg_overrides {
137            let path = override_path.as_ref();
138            tracing::debug!("adding config override: {}", path.display());
139            config_builder = config_builder
140                .add_source(File::new(&path.to_string_lossy(), FileFormat::Toml).required(true));
141        }
142
143        // Environment variables still take highest precedence
144        config_builder = config_builder
145            .add_source(config::Environment::with_prefix("FIREBLOCKS").try_parsing(true));
146
147        let conf: Self = config_builder.build()?.try_deserialize()?;
148        tracing::trace!("loaded config {conf:#?}");
149        Ok(conf)
150    }
151
152    pub fn with_overrides<P: AsRef<Path>>(
153        cfg: P,
154        overrides: impl IntoIterator<Item = P>,
155    ) -> Result<Self> {
156        let override_vec: Vec<P> = overrides.into_iter().collect();
157        Self::new(cfg, &override_vec)
158    }
159
160    /// Load configuration from XDG config directory
161    /// (~/.config/fireblocks/default.toml)
162    #[cfg(feature = "xdg")]
163    pub fn init() -> Result<Self> {
164        Self::init_with_profiles::<&str>(&[])
165    }
166
167    /// Load configuration from XDG config directory with additional profile
168    /// overrides
169    ///
170    /// Loads ~/.config/fireblocks/default.toml as base config, then applies
171    /// each profile from ~/.config/fireblocks/{profile}.toml in order.
172    ///
173    /// # Example
174    /// ```rust,no_run
175    /// use fireblocks_config::FireblocksConfig;
176    ///
177    /// // Load default config only
178    /// let config = FireblocksConfig::init()?;
179    ///
180    /// // Load default + production profile
181    /// let config = FireblocksConfig::init_with_profiles(&["production"])?;
182    ///
183    /// // Load default + staging + production (layered)
184    /// let config = FireblocksConfig::init_with_profiles(&["staging", "production"])?;
185    /// # Ok::<(), Box<dyn std::error::Error>>(())
186    /// ```
187    #[cfg(feature = "xdg")]
188    pub fn init_with_profiles<S: AsRef<str>>(profiles: &[S]) -> Result<Self> {
189        let xdg_app = XdgApp::new("fireblocks")?;
190        let default_config = xdg_app.app_config_file("default.toml")?;
191
192        tracing::debug!("loading default config: {}", default_config.display());
193
194        let mut profile_configs = Vec::new();
195        for profile in profiles {
196            let profile_file = format!("{}.toml", profile.as_ref());
197            let profile_config = xdg_app.app_config_file(&profile_file)?;
198            if profile_config.exists() {
199                tracing::debug!("adding profile config: {}", profile_config.display());
200                profile_configs.push(profile_config);
201            } else {
202                tracing::warn!("profile config not found: {}", profile_config.display());
203            }
204        }
205
206        Self::new(default_config, &profile_configs)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use {super::*, std::path::PathBuf};
213
214    #[ignore]
215    #[test]
216    fn test_gpg_config() -> anyhow::Result<()> {
217        let b = "examples/config-gpg.toml";
218        let cfg = FireblocksConfig::new(b, &[])?;
219        cfg.get_key()?;
220        Ok(())
221    }
222
223    #[test]
224    fn test_config() -> anyhow::Result<()> {
225        let b = "examples/default.toml";
226        let cfg = FireblocksConfig::new(b, &[])?;
227        assert_eq!("blah", cfg.api_key);
228        assert!(cfg.secret_path.is_some());
229        if let Some(p) = cfg.secret_path.as_ref() {
230            assert_eq!(PathBuf::from("examples/test.pem"), *p);
231        }
232        assert_eq!("https://sandbox-api.fireblocks.io/v1", cfg.url);
233        assert_eq!(OutputFormat::Table, cfg.display_config.output);
234        unsafe {
235            std::env::set_var("FIREBLOCKS_SECRET", "override");
236        }
237        let cfg = FireblocksConfig::new(b, &[])?;
238        assert!(cfg.secret.is_some());
239        assert_eq!(String::from("override").as_bytes(), cfg.get_key()?);
240        if let Some(ref k) = cfg.secret_path {
241            assert_eq!(PathBuf::from("examples/test.pem"), *k);
242        }
243
244        assert_eq!(cfg.signer.vault, "0");
245        unsafe {
246            std::env::remove_var("FIREBLOCKS_SECRET");
247        }
248        Ok(())
249    }
250
251    #[test]
252    fn test_config_override() -> anyhow::Result<()> {
253        let b = "examples/default.toml";
254        let cfg_override = "examples/override.toml";
255        let cfg = FireblocksConfig::with_overrides(b, vec![cfg_override])?;
256        assert_eq!("production", cfg.api_key);
257        assert!(cfg.secret_path.is_some());
258        if let Some(p) = cfg.secret_path.as_ref() {
259            assert_eq!(PathBuf::from("examples/test.pem"), *p);
260        }
261        assert_eq!("https://api.fireblocks.io/v1", cfg.url);
262        assert_eq!(OutputFormat::Table, cfg.display_config.output);
263        Ok(())
264    }
265
266    #[test]
267    fn test_embedded_key() -> anyhow::Result<()> {
268        let b = "examples/default.toml";
269        let cfg_override = "examples/embedded.toml";
270        let cfg = FireblocksConfig::new(b, &[cfg_override])?;
271        assert!(cfg.secret.is_some());
272        let secret = cfg.secret.unwrap();
273        assert_eq!(String::from("i am a secret").as_bytes(), secret.as_bytes());
274        Ok(())
275    }
276
277    #[test]
278    fn test_duration_parsing() -> anyhow::Result<()> {
279        let b = "examples/default.toml";
280        let cfg = FireblocksConfig::new(b, &[])?;
281
282        // Verify that string values in TOML are parsed as Duration
283        assert_eq!(cfg.signer.poll_timeout, Duration::from_secs(120));
284        assert_eq!(cfg.signer.poll_interval, Duration::from_secs(5));
285
286        Ok(())
287    }
288
289    #[cfg(feature = "xdg")]
290    #[test]
291    fn test_xdg_init() {
292        // This test just ensures the XDG methods compile and can be called
293        // In a real environment, it would try to load from ~/.config/fireblocks/
294        match FireblocksConfig::init() {
295            Ok(_) => {
296                // Config loaded successfully from XDG directory
297            }
298            Err(_) => {
299                // Expected if no config exists in XDG directory
300                // This is fine for the compilation test
301            }
302        }
303
304        // Test with &str slice
305        match FireblocksConfig::init_with_profiles(&["test", "production"]) {
306            Ok(_) => {
307                // Config loaded successfully
308            }
309            Err(_) => {
310                // Expected if no config exists
311            }
312        }
313
314        // Test with Vec<String> to verify flexibility
315        let profiles: Vec<String> = vec!["staging".to_string(), "production".to_string()];
316        match FireblocksConfig::init_with_profiles(&profiles) {
317            Ok(_) => {
318                // Config loaded successfully
319            }
320            Err(_) => {
321                // Expected if no config exists
322            }
323        }
324    }
325}