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