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