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 self.is_get() {
59 return Ok(resolve_server_url()?.into());
60 }
61
62 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 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#[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
123pub(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
130pub(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
139pub fn read_config_json() -> Result<Option<ConfigFile>> {
141 read_config_json_from(&config_json_path()?)
142}
143
144pub 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
171impl 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
204fn 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
214fn 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}