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