fireblocks_config/
config.rs1#[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
36fn 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 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 if let Some(ref key) = self.secret {
86 return Ok(key.clone().into_bytes());
87 }
88
89 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 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 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 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 #[cfg(feature = "xdg")]
163 pub fn init() -> Result<Self> {
164 Self::init_with_profiles::<&str>(&[])
165 }
166
167 #[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 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 match FireblocksConfig::init() {
295 Ok(_) => {
296 }
298 Err(_) => {
299 }
302 }
303
304 match FireblocksConfig::init_with_profiles(&["test", "production"]) {
306 Ok(_) => {
307 }
309 Err(_) => {
310 }
312 }
313
314 let profiles: Vec<String> = vec!["staging".to_string(), "production".to_string()];
316 match FireblocksConfig::init_with_profiles(&profiles) {
317 Ok(_) => {
318 }
320 Err(_) => {
321 }
323 }
324 }
325}