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 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 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 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#[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
130pub fn read_config_json() -> Result<Option<ConfigFile>> {
132 read_config_json_from(&config_json_path()?)
133}
134
135pub 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
162impl 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
195fn 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
205fn 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}