Skip to main content

bw/platform/
config.rs

1use std::path::{Path, PathBuf};
2
3use clap::Subcommand;
4use color_eyre::eyre::{Result, WrapErr, eyre};
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    client_state::{AnyState, BwCommand},
9    platform::appdata::appdata_dir,
10    render::CommandResult,
11};
12
13#[derive(Subcommand, Clone)]
14pub enum ConfigCommand {
15    Server {
16        base_url: Option<String>,
17        #[arg(
18            long,
19            help = "Provides a custom web vault URL that differs from the base URL."
20        )]
21        web_vault: Option<String>,
22        #[arg(
23            long,
24            help = "Provides a custom API URL that differs from the base URL."
25        )]
26        api: Option<String>,
27        #[arg(
28            long,
29            help = "Provides a custom identity URL that differs from the base URL."
30        )]
31        identity: Option<String>,
32        #[arg(
33            long,
34            help = "Provides a custom icons service URL that differs from the base URL."
35        )]
36        icons: Option<String>,
37        #[arg(
38            long,
39            help = "Provides a custom notifications URL that differs from the base URL."
40        )]
41        notifications: Option<String>,
42        #[arg(
43            long,
44            help = "Provides a custom events URL that differs from the base URL."
45        )]
46        events: Option<String>,
47
48        #[arg(long, help = "Provides the URL for your Key Connector server.")]
49        key_connector: Option<String>,
50    },
51}
52
53impl BwCommand for ConfigCommand {
54    type Client = AnyState;
55
56    async fn run(self, state: AnyState) -> CommandResult {
57        // If we're not provided any values, then this is a request to get the current server URL.
58        if self.is_get() {
59            let server = read_config_json()?
60                .and_then(|c| c.server)
61                .unwrap_or_else(|| "https://bitwarden.com".into());
62            return Ok(server.into());
63        }
64
65        // If we are provided any values, then this is a request to set the server URL,
66        // which can only be done when no user is logged in.
67        if state.user.is_some() {
68            return Err(eyre!("Logout required before server config update."));
69        }
70
71        let config: ConfigFile = self.into();
72        write_config_json(&config)?;
73
74        Ok("Saved setting `config`.".into())
75    }
76}
77
78impl ConfigCommand {
79    /// True when the invocation should return the saved server URL instead of writing one.
80    ///
81    /// GET when the base URL is missing or empty AND no per-service URL flag is set.
82    /// Node CLI's `config.command.ts` omits `--key-connector` from this check, so passing
83    /// only that flag silently no-ops. We treat that as a Node CLI bug rather than parity
84    /// to preserve: `--key-connector X` alone takes the SET path here.
85    fn is_get(&self) -> bool {
86        let ConfigCommand::Server {
87            base_url,
88            web_vault,
89            api,
90            identity,
91            icons,
92            notifications,
93            events,
94            key_connector,
95        } = self;
96
97        base_url.as_deref().is_none_or(|s| s.trim().is_empty())
98            && web_vault.is_none()
99            && api.is_none()
100            && identity.is_none()
101            && icons.is_none()
102            && notifications.is_none()
103            && events.is_none()
104            && key_connector.is_none()
105    }
106}
107
108/// On-disk shape of the CLI's legacy server-URL config, written by
109/// `bw config server` and read by `bw status` when no user is logged in.
110///
111/// Field names are snake_case so the file is human-editable and matches the
112/// breakdown's documented layout (see PM-37214).
113#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub struct ConfigFile {
116    pub server: Option<String>,
117    pub web_vault: Option<String>,
118    pub api: Option<String>,
119    pub identity: Option<String>,
120    pub icons: Option<String>,
121    pub notifications: Option<String>,
122    pub events: Option<String>,
123    pub key_connector: Option<String>,
124}
125
126fn config_json_path() -> Result<PathBuf> {
127    Ok(appdata_dir()?.join("config.json"))
128}
129
130/// Reads the config JSON from disk
131pub fn read_config_json() -> Result<Option<ConfigFile>> {
132    read_config_json_from(&config_json_path()?)
133}
134
135/// Writes the config JSON to disk, creating parent directories as needed
136pub fn write_config_json(config: &ConfigFile) -> Result<()> {
137    write_config_json_to(&config_json_path()?, config)
138}
139
140fn read_config_json_from(path: &Path) -> Result<Option<ConfigFile>> {
141    let bytes = match std::fs::read(path) {
142        Ok(b) => b,
143        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
144        Err(e) => {
145            return Err(e).wrap_err_with(|| format!("Failed to read {}", path.display()));
146        }
147    };
148    serde_json::from_slice(&bytes)
149        .map(Some)
150        .wrap_err_with(|| format!("Failed to parse {}", path.display()))
151}
152
153fn write_config_json_to(path: &Path, config: &ConfigFile) -> Result<()> {
154    if let Some(parent) = path.parent() {
155        std::fs::create_dir_all(parent)
156            .wrap_err_with(|| format!("Failed to create {}", parent.display()))?;
157    }
158    let json = serde_json::to_vec_pretty(config)?;
159    std::fs::write(path, json).wrap_err_with(|| format!("Failed to write {}", path.display()))
160}
161
162/// Builds the on-disk `ConfigFile` from a SET invocation. Only meaningful after
163/// [`ConfigCommand::is_get`] has returned `false` — the GET path does not write.
164///
165/// URL fields are normalized to match the Node CLI: the base URL's cloud-default
166/// aliases collapse to `None`, then every URL is run through [`format_url`].
167impl From<ConfigCommand> for ConfigFile {
168    fn from(cmd: ConfigCommand) -> Self {
169        let ConfigCommand::Server {
170            base_url,
171            web_vault,
172            api,
173            identity,
174            icons,
175            notifications,
176            events,
177            key_connector,
178        } = cmd;
179
180        ConfigFile {
181            server: base_url
182                .and_then(collapse_default_alias)
183                .and_then(format_url),
184            web_vault: web_vault.and_then(format_url),
185            api: api.and_then(format_url),
186            identity: identity.and_then(format_url),
187            icons: icons.and_then(format_url),
188            notifications: notifications.and_then(format_url),
189            events: events.and_then(format_url),
190            key_connector: key_connector.and_then(format_url),
191        }
192    }
193}
194
195/// Reproduces Node CLI's literal-string check in `config.command.ts`: the strings
196/// `"null"`, `"bitwarden.com"`, and `"https://bitwarden.com"` collapse to a missing
197/// server (i.e. fall back to the default cloud URL on read).
198fn collapse_default_alias(url: String) -> Option<String> {
199    match url.as_str() {
200        "null" | "bitwarden.com" | "https://bitwarden.com" => None,
201        _ => Some(url),
202    }
203}
204
205/// Reproduces Node CLI's `formatUrl` from `default-environment.service.ts`:
206/// empty input collapses to `None`, trailing slashes are stripped, `https://`
207/// is prepended when no scheme is present, and surrounding whitespace is trimmed.
208fn format_url(url: String) -> Option<String> {
209    if url.is_empty() {
210        return None;
211    }
212    let stripped = url.trim_end_matches('/');
213    let with_scheme = if stripped.starts_with("http://") || stripped.starts_with("https://") {
214        stripped.to_string()
215    } else {
216        format!("https://{stripped}")
217    };
218    Some(with_scheme.trim().to_string())
219}
220
221#[cfg(test)]
222mod tests {
223    use std::path::PathBuf;
224
225    use super::*;
226
227    fn tempdir() -> PathBuf {
228        let dir = std::env::temp_dir().join(format!(
229            "bw-config-test-{}-{}",
230            std::process::id(),
231            uuid::Uuid::new_v4(),
232        ));
233        std::fs::create_dir_all(&dir).expect("tempdir");
234        dir
235    }
236
237    #[test]
238    fn read_returns_none_when_file_missing() {
239        let path = tempdir().join("config.json");
240        assert!(read_config_json_from(&path).unwrap().is_none());
241    }
242
243    #[test]
244    fn write_then_read_roundtrips_all_fields() {
245        let path = tempdir().join("config.json");
246        let original = ConfigFile {
247            server: Some("https://self-hosted.example.com".into()),
248            web_vault: Some("https://vault.example.com".into()),
249            api: Some("https://api.example.com".into()),
250            identity: Some("https://identity.example.com".into()),
251            icons: Some("https://icons.example.com".into()),
252            notifications: Some("https://notifications.example.com".into()),
253            events: Some("https://events.example.com".into()),
254            key_connector: Some("https://kc.example.com".into()),
255        };
256
257        write_config_json_to(&path, &original).unwrap();
258        let loaded = read_config_json_from(&path).unwrap().unwrap();
259
260        assert_eq!(loaded, original);
261    }
262
263    #[test]
264    fn write_creates_parent_directory() {
265        let path = tempdir().join("nested").join("dir").join("config.json");
266        write_config_json_to(&path, &ConfigFile::default()).unwrap();
267        assert!(path.exists());
268    }
269
270    #[test]
271    fn serialized_keys_are_snake_case() {
272        let config = ConfigFile {
273            web_vault: Some("a".into()),
274            key_connector: Some("b".into()),
275            ..ConfigFile::default()
276        };
277        let json = serde_json::to_string(&config).unwrap();
278        assert!(json.contains("\"web_vault\""), "got: {json}");
279        assert!(json.contains("\"key_connector\""), "got: {json}");
280    }
281
282    #[test]
283    fn collapse_default_alias_drops_cloud_aliases() {
284        assert_eq!(collapse_default_alias("null".into()), None);
285        assert_eq!(collapse_default_alias("bitwarden.com".into()), None);
286        assert_eq!(collapse_default_alias("https://bitwarden.com".into()), None);
287        assert_eq!(
288            collapse_default_alias("https://self-hosted.example.com".into()),
289            Some("https://self-hosted.example.com".into()),
290        );
291    }
292
293    #[test]
294    fn format_url_returns_none_for_empty() {
295        assert_eq!(format_url(String::new()), None);
296    }
297
298    #[test]
299    fn format_url_prepends_https_when_scheme_missing() {
300        assert_eq!(
301            format_url("bw.example.com".into()),
302            Some("https://bw.example.com".into()),
303        );
304    }
305
306    #[test]
307    fn format_url_preserves_existing_scheme() {
308        assert_eq!(
309            format_url("http://bw.example.com".into()),
310            Some("http://bw.example.com".into()),
311        );
312        assert_eq!(
313            format_url("https://bw.example.com".into()),
314            Some("https://bw.example.com".into()),
315        );
316    }
317
318    #[test]
319    fn format_url_strips_trailing_slashes() {
320        assert_eq!(
321            format_url("https://bw.example.com/".into()),
322            Some("https://bw.example.com".into()),
323        );
324        assert_eq!(
325            format_url("https://bw.example.com///".into()),
326            Some("https://bw.example.com".into()),
327        );
328    }
329
330    #[test]
331    fn format_url_from_command_normalizes_every_url_field() {
332        let cmd = ConfigCommand::Server {
333            base_url: Some("bw.example.com/".into()),
334            web_vault: Some("vault.example.com/".into()),
335            api: Some("api.example.com/".into()),
336            identity: Some("id.example.com/".into()),
337            icons: Some("icons.example.com/".into()),
338            notifications: Some("notify.example.com/".into()),
339            events: Some("events.example.com/".into()),
340            key_connector: Some("kc.example.com/".into()),
341        };
342        let file: ConfigFile = cmd.into();
343        assert_eq!(file.server.as_deref(), Some("https://bw.example.com"));
344        assert_eq!(file.web_vault.as_deref(), Some("https://vault.example.com"));
345        assert_eq!(file.api.as_deref(), Some("https://api.example.com"));
346        assert_eq!(file.identity.as_deref(), Some("https://id.example.com"));
347        assert_eq!(file.icons.as_deref(), Some("https://icons.example.com"));
348        assert_eq!(
349            file.notifications.as_deref(),
350            Some("https://notify.example.com"),
351        );
352        assert_eq!(file.events.as_deref(), Some("https://events.example.com"));
353        assert_eq!(
354            file.key_connector.as_deref(),
355            Some("https://kc.example.com")
356        );
357    }
358
359    #[test]
360    fn from_command_collapses_base_url_alias_before_formatting() {
361        let cmd = ConfigCommand::Server {
362            base_url: Some("https://bitwarden.com".into()),
363            web_vault: None,
364            api: None,
365            identity: None,
366            icons: None,
367            notifications: None,
368            events: None,
369            key_connector: None,
370        };
371        let file: ConfigFile = cmd.into();
372        assert_eq!(file.server, None);
373    }
374}