Skip to main content

bwx/
config.rs

1use crate::prelude::*;
2
3use std::io::{Read as _, Write as _};
4use std::sync::{Arc, OnceLock};
5
6use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
7
8static CACHED: OnceLock<Arc<Config>> = OnceLock::new();
9
10#[derive(serde::Serialize, serde::Deserialize, Debug)]
11pub struct Config {
12    pub email: Option<String>,
13    pub sso_id: Option<String>,
14    pub base_url: Option<String>,
15    pub identity_url: Option<String>,
16    pub ui_url: Option<String>,
17    pub notifications_url: Option<String>,
18    #[serde(default = "default_lock_timeout")]
19    pub lock_timeout: u64,
20    #[serde(default = "default_sync_interval")]
21    pub sync_interval: u64,
22    #[serde(default = "default_pinentry")]
23    pub pinentry: String,
24    pub client_cert_path: Option<std::path::PathBuf>,
25    #[serde(default)]
26    pub ssh_confirm_sign: bool,
27    /// On macOS, controls how the master-password prompt is shown at unlock
28    /// time. Default `true` renders a native modal (works from daemonized
29    /// contexts — ssh-sign, Finder-launched GUI git, etc.). Set `false` to
30    /// fall back to pinentry. No effect on other platforms.
31    #[serde(default = "default_macos_unlock_dialog")]
32    pub macos_unlock_dialog: bool,
33    #[serde(default = "default_logging")]
34    pub logging: bool,
35    #[serde(
36        default,
37        with = "touchid_gate_serde",
38        skip_serializing_if = "is_touchid_gate_off"
39    )]
40    pub touchid_gate: crate::touchid::Gate,
41    // backcompat, no longer generated in new configs
42    #[serde(skip_serializing)]
43    pub device_id: Option<String>,
44}
45
46impl Default for Config {
47    fn default() -> Self {
48        Self {
49            email: None,
50            sso_id: None,
51            base_url: None,
52            identity_url: None,
53            ui_url: None,
54            notifications_url: None,
55            lock_timeout: default_lock_timeout(),
56            sync_interval: default_sync_interval(),
57            pinentry: default_pinentry(),
58            client_cert_path: None,
59            ssh_confirm_sign: false,
60            macos_unlock_dialog: default_macos_unlock_dialog(),
61            logging: default_logging(),
62            touchid_gate: crate::touchid::Gate::Off,
63            device_id: None,
64        }
65    }
66}
67
68pub fn default_lock_timeout() -> u64 {
69    3600
70}
71
72pub fn default_sync_interval() -> u64 {
73    3600
74}
75
76pub fn default_pinentry() -> String {
77    "pinentry".to_string()
78}
79
80pub const fn default_macos_unlock_dialog() -> bool {
81    cfg!(target_os = "macos")
82}
83
84pub const fn default_logging() -> bool {
85    false
86}
87
88#[allow(clippy::trivially_copy_pass_by_ref)]
89fn is_touchid_gate_off(g: &crate::touchid::Gate) -> bool {
90    matches!(g, crate::touchid::Gate::Off)
91}
92
93mod touchid_gate_serde {
94    use std::str::FromStr as _;
95
96    use serde::{Deserialize as _, Deserializer, Serializer};
97
98    #[allow(clippy::trivially_copy_pass_by_ref)]
99    pub fn serialize<S: Serializer>(
100        g: &crate::touchid::Gate,
101        s: S,
102    ) -> Result<S::Ok, S::Error> {
103        s.serialize_str(&g.to_string())
104    }
105
106    pub fn deserialize<'de, D: Deserializer<'de>>(
107        d: D,
108    ) -> Result<crate::touchid::Gate, D::Error> {
109        let s = String::deserialize(d)?;
110        crate::touchid::Gate::from_str(&s).map_err(serde::de::Error::custom)
111    }
112}
113
114impl Config {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    pub fn load() -> Result<Self> {
120        let file = crate::dirs::config_file();
121        let mut fh = std::fs::File::open(&file).map_err(|source| {
122            Error::LoadConfig {
123                source,
124                file: file.clone(),
125            }
126        })?;
127        let mut json = String::new();
128        fh.read_to_string(&mut json)
129            .map_err(|source| Error::LoadConfig {
130                source,
131                file: file.clone(),
132            })?;
133        let mut slf: Self = serde_json::from_str(&json)
134            .map_err(|source| Error::LoadConfigJson { source, file })?;
135        if slf.lock_timeout == 0 {
136            log::warn!("lock_timeout must be greater than 0");
137            slf.lock_timeout = default_lock_timeout();
138        }
139        Ok(slf)
140    }
141
142    pub async fn load_async() -> Result<Self> {
143        let file = crate::dirs::config_file();
144        let mut fh =
145            tokio::fs::File::open(&file).await.map_err(|source| {
146                Error::LoadConfigAsync {
147                    source,
148                    file: file.clone(),
149                }
150            })?;
151        let mut json = String::new();
152        fh.read_to_string(&mut json).await.map_err(|source| {
153            Error::LoadConfigAsync {
154                source,
155                file: file.clone(),
156            }
157        })?;
158        let mut slf: Self = serde_json::from_str(&json)
159            .map_err(|source| Error::LoadConfigJson { source, file })?;
160        if slf.lock_timeout == 0 {
161            log::warn!("lock_timeout must be greater than 0");
162            slf.lock_timeout = default_lock_timeout();
163        }
164        Ok(slf)
165    }
166
167    pub fn save(&self) -> Result<()> {
168        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
169        let file = crate::dirs::config_file();
170        // unwrap is safe here because Self::filename is explicitly
171        // constructed as a filename in a directory
172        std::fs::create_dir_all(file.parent().unwrap()).map_err(
173            |source| Error::SaveConfig {
174                source,
175                file: file.clone(),
176            },
177        )?;
178        let mut fh = std::fs::OpenOptions::new()
179            .write(true)
180            .create(true)
181            .truncate(true)
182            .mode(0o600)
183            .open(&file)
184            .map_err(|source| Error::SaveConfig {
185                source,
186                file: file.clone(),
187            })?;
188        // `OpenOptions::mode` only applies on file creation; tighten
189        // unconditionally so a pre-existing loose-mode file is corrected
190        // on every write.
191        fh.set_permissions(std::fs::Permissions::from_mode(0o600))
192            .map_err(|source| Error::SaveConfig {
193                source,
194                file: file.clone(),
195            })?;
196        fh.write_all(
197            serde_json::to_string(self)
198                .map_err(|source| Error::SaveConfigJson {
199                    source,
200                    file: file.clone(),
201                })?
202                .as_bytes(),
203        )
204        .map_err(|source| Error::SaveConfig { source, file })?;
205        Ok(())
206    }
207
208    /// Load once per process and reuse on subsequent calls. Safe for the
209    /// short-lived `bwx` CLI where the config file isn't mutated mid-run.
210    /// Mutating commands (`bwx config set`/`unset`) must keep using
211    /// `load()` so they see fresh state.
212    pub fn load_cached() -> Result<Arc<Self>> {
213        if let Some(c) = CACHED.get() {
214            return Ok(Arc::clone(c));
215        }
216        let loaded = Arc::new(Self::load()?);
217        Ok(Arc::clone(CACHED.get_or_init(|| loaded)))
218    }
219
220    pub fn validate() -> Result<()> {
221        let config = Self::load_cached()?;
222        if config.email.is_none() {
223            return Err(Error::ConfigMissingEmail);
224        }
225        Ok(())
226    }
227
228    pub fn base_url(&self) -> String {
229        self.base_url.clone().map_or_else(
230            || "https://api.bitwarden.com".to_string(),
231            |url| {
232                let clean_url = url.trim_end_matches('/');
233                if clean_url == "https://api.bitwarden.eu" {
234                    "https://api.bitwarden.eu".to_string()
235                } else {
236                    format!("{clean_url}/api")
237                }
238            },
239        )
240    }
241
242    pub fn identity_url(&self) -> String {
243        self.identity_url.clone().unwrap_or_else(|| {
244            self.base_url.clone().map_or_else(
245                || "https://identity.bitwarden.com".to_string(),
246                |url| {
247                    let clean_url = url.trim_end_matches('/');
248                    if clean_url == "https://api.bitwarden.eu" {
249                        "https://identity.bitwarden.eu".to_string()
250                    } else {
251                        format!("{clean_url}/identity")
252                    }
253                },
254            )
255        })
256    }
257
258    pub fn ui_url(&self) -> String {
259        self.ui_url.clone().unwrap_or_else(|| {
260            self.base_url.clone().map_or_else(
261                || "https://vault.bitwarden.com".to_string(),
262                |url| {
263                    let clean_url = url.trim_end_matches('/');
264                    if clean_url == "https://api.bitwarden.eu" {
265                        "https://vault.bitwarden.eu".to_string()
266                    } else {
267                        clean_url.to_string()
268                    }
269                },
270            )
271        })
272    }
273
274    pub fn notifications_url(&self) -> String {
275        self.notifications_url.clone().unwrap_or_else(|| {
276            self.base_url.clone().map_or_else(
277                || "https://notifications.bitwarden.com".to_string(),
278                |url| {
279                    let clean_url = url.trim_end_matches('/');
280                    if clean_url == "https://api.bitwarden.eu" {
281                        "https://notifications.bitwarden.eu".to_string()
282                    } else {
283                        format!("{clean_url}/notifications")
284                    }
285                },
286            )
287        })
288    }
289
290    pub fn client_cert_path(&self) -> Option<&std::path::Path> {
291        self.client_cert_path.as_deref()
292    }
293
294    pub fn server_name(&self) -> String {
295        self.base_url
296            .clone()
297            .unwrap_or_else(|| "default".to_string())
298    }
299}
300
301pub async fn device_id(config: &Config) -> Result<String> {
302    let file = crate::dirs::device_id_file();
303    if let Ok(mut fh) = tokio::fs::File::open(&file).await {
304        let mut s = String::new();
305        fh.read_to_string(&mut s)
306            .await
307            .map_err(|e| Error::LoadDeviceId {
308                source: e,
309                file: file.clone(),
310            })?;
311        Ok(s.trim().to_string())
312    } else {
313        use std::os::unix::fs::PermissionsExt as _;
314        let id = config.device_id.as_ref().map_or_else(
315            || crate::uuid::new_v4().to_string(),
316            String::to_string,
317        );
318        let mut fh = tokio::fs::OpenOptions::new()
319            .write(true)
320            .create(true)
321            .truncate(true)
322            .mode(0o600)
323            .open(&file)
324            .await
325            .map_err(|e| Error::LoadDeviceId {
326                source: e,
327                file: file.clone(),
328            })?;
329        // `OpenOptions::mode` only applies on create; tighten
330        // unconditionally so a pre-existing loose-mode file gets
331        // corrected on the next write.
332        fh.set_permissions(std::fs::Permissions::from_mode(0o600))
333            .await
334            .map_err(|e| Error::LoadDeviceId {
335                source: e,
336                file: file.clone(),
337            })?;
338        fh.write_all(id.as_bytes()).await.map_err(|e| {
339            Error::LoadDeviceId {
340                source: e,
341                file: file.clone(),
342            }
343        })?;
344        Ok(id)
345    }
346}