1use std::collections::HashMap;
40
41use serde::Deserialize;
42
43use crate::config::BitwConfig;
44use crate::error::BitwError;
45
46#[derive(Debug, Deserialize)]
50pub struct BwCipher {
51 #[serde(rename = "type")]
53 pub cipher_type: u8,
54 pub name: String,
56 pub login: Option<BwLogin>,
58 #[serde(default)]
60 pub fields: Vec<BwField>,
61 #[serde(rename = "folderId")]
63 pub folder_id: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct BwLogin {
68 pub username: Option<String>,
69 pub password: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
76pub struct BwField {
77 pub name: Option<String>,
78 pub value: Option<String>,
79 #[serde(rename = "type")]
81 pub field_type: u8,
82}
83
84pub fn normalize_item_name(name: &str) -> String {
94 name.replace([' ', '-', '/'], "_").to_uppercase()
95}
96
97pub fn build_key(item_prefix: &str, suffix: &str) -> String {
105 let norm_suffix = suffix.replace([' ', '-', '/'], "_").to_uppercase();
106 format!("{item_prefix}_{norm_suffix}")
107}
108
109fn extract_session_token(output: &str) -> Option<String> {
123 for line in output.lines() {
124 if let Some(rest) = line
125 .find("BW_SESSION=")
126 .map(|i| &line[i + "BW_SESSION=".len()..])
127 {
128 let token = rest.trim().trim_matches('"').trim_matches('\'').to_string();
130 if !token.is_empty() {
131 return Some(token);
132 }
133 }
134 }
135 None
136}
137
138pub fn pull_items(
155 cfg: &BitwConfig,
156 password_env: &str,
157 folder_id: Option<&str>,
158) -> Result<Vec<(String, String)>, BitwError> {
159 if std::env::var(password_env)
162 .ok()
163 .filter(|v| !v.is_empty())
164 .is_none()
165 {
166 return Err(BitwError::Config(format!(
167 "env var `{password_env}` is not set or is empty — \
168 it must contain the Bitwarden master password for `bw unlock`"
169 )));
170 }
171
172 let default_identity = BitwConfig::default_identity_url();
174 if cfg.identity_url != default_identity {
175 let server_url = cfg
177 .identity_url
178 .trim_end_matches('/')
179 .trim_end_matches("/identity");
180 run_bw(&["config", "server", server_url], None, None)?;
181 }
182
183 let login_output = run_bw(
185 &[
186 "login",
187 "--apikey",
188 "--clientid",
189 &cfg.client_id,
190 "--clientsecret",
191 &cfg.client_secret,
192 ],
193 None,
194 None,
195 )?;
196 tracing::debug!(bytes = login_output.len(), "bw login completed");
197
198 let unlock_output =
200 run_bw(&["unlock", "--passwordenv", password_env], None, None).map_err(|e| match e {
201 BitwError::ListFailed { status, stderr } => BitwError::UnlockFailed { status, stderr },
202 other => other,
203 })?;
204
205 let session_token =
206 extract_session_token(&unlock_output).ok_or(BitwError::SessionTokenMissing)?;
207
208 tracing::debug!("bw unlock succeeded, session token obtained");
209
210 let folderid_owned: String = folder_id.unwrap_or("").to_string();
214 let list_args: Vec<&str> = if folder_id.is_some() {
215 vec!["list", "items", "--folderid", &folderid_owned]
216 } else {
217 vec!["list", "items"]
218 };
219
220 let list_json = run_bw(&list_args, Some(&session_token), None)?;
221
222 if let Err(e) = run_bw(&["lock"], Some(&session_token), None) {
224 tracing::warn!("bw lock failed (non-fatal): {e}");
225 }
226
227 let ciphers: Vec<BwCipher> =
229 serde_json::from_str(&list_json).map_err(|e| BitwError::ParseError(e.to_string()))?;
230
231 Ok(map_ciphers_to_kv(&ciphers))
232}
233
234fn run_bw(
243 args: &[&str],
244 session_token: Option<&str>,
245 extra_env: Option<&HashMap<String, String>>,
246) -> Result<String, BitwError> {
247 let mut cmd = std::process::Command::new("bw");
248 cmd.args(args);
249
250 cmd.env_remove("BW_SESSION");
253 if let Some(tok) = session_token {
254 cmd.env("BW_SESSION", tok);
255 }
256
257 if let Some(env) = extra_env {
258 for (k, v) in env {
259 cmd.env(k, v);
260 }
261 }
262
263 let output = cmd.output().map_err(|e| {
264 if e.kind() == std::io::ErrorKind::NotFound {
265 BitwError::CliNotFound
266 } else {
267 BitwError::ListFailed {
268 status: -1,
269 stderr: e.to_string(),
270 }
271 }
272 })?;
273
274 if !output.status.success() {
275 let status = output.status.code().unwrap_or(-1);
276 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
277 return Err(BitwError::ListFailed { status, stderr });
278 }
279
280 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
281}
282
283pub fn map_ciphers_to_kv(ciphers: &[BwCipher]) -> Vec<(String, String)> {
288 let mut pairs = Vec::new();
289
290 for cipher in ciphers {
291 if cipher.cipher_type != 1 {
292 continue; }
294
295 let prefix = normalize_item_name(&cipher.name);
296
297 if let Some(login) = &cipher.login {
298 if let Some(username) = login.username.as_deref().filter(|s| !s.is_empty()) {
299 pairs.push((build_key(&prefix, "USERNAME"), username.to_string()));
300 }
301 if let Some(password) = login.password.as_deref().filter(|s| !s.is_empty()) {
302 pairs.push((build_key(&prefix, "PASSWORD"), password.to_string()));
303 }
304 }
305
306 for field in &cipher.fields {
307 if field.field_type == 2 {
308 continue; }
310 let label = match field.name.as_deref().filter(|s| !s.is_empty()) {
311 Some(l) => l,
312 None => continue,
313 };
314 let value = match field.value.as_deref().filter(|s| !s.is_empty()) {
315 Some(v) => v,
316 None => continue,
317 };
318 pairs.push((build_key(&prefix, label), value.to_string()));
319 }
320 }
321
322 pairs
323}
324
325#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn make_login_cipher(name: &str, username: Option<&str>, password: Option<&str>) -> BwCipher {
332 BwCipher {
333 cipher_type: 1,
334 name: name.to_string(),
335 login: Some(BwLogin {
336 username: username.map(|s| s.to_string()),
337 password: password.map(|s| s.to_string()),
338 }),
339 fields: vec![],
340 folder_id: None,
341 }
342 }
343
344 #[test]
347 fn normalize_spaces_to_underscore() {
348 assert_eq!(normalize_item_name("Database Creds"), "DATABASE_CREDS");
349 }
350
351 #[test]
352 fn normalize_hyphens_to_underscore() {
353 assert_eq!(normalize_item_name("my-api-key"), "MY_API_KEY");
354 }
355
356 #[test]
357 fn normalize_slash_to_underscore() {
358 assert_eq!(normalize_item_name("prod/db"), "PROD_DB");
359 }
360
361 #[test]
362 fn normalize_already_upper() {
363 assert_eq!(normalize_item_name("FOO_BAR"), "FOO_BAR");
364 }
365
366 #[test]
369 fn build_key_username() {
370 assert_eq!(
371 build_key("DATABASE_CREDS", "USERNAME"),
372 "DATABASE_CREDS_USERNAME"
373 );
374 }
375
376 #[test]
377 fn build_key_custom_field_normalises_suffix() {
378 assert_eq!(build_key("MY_APP", "host name"), "MY_APP_HOST_NAME");
379 }
380
381 #[test]
384 fn extract_session_unix_export_format() {
385 let output = r#"Your vault is now unlocked!
386
387To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
388$ export BW_SESSION="AbCdEfGhIjKlMn=="
389> $env:BW_SESSION="AbCdEfGhIjKlMn=="
390"#;
391 assert_eq!(
392 extract_session_token(output),
393 Some("AbCdEfGhIjKlMn==".into())
394 );
395 }
396
397 #[test]
398 fn extract_session_windows_format() {
399 let output = r#"Your vault is now unlocked!
400> $env:BW_SESSION="Win32TokenHere=="
401"#;
402 assert_eq!(
403 extract_session_token(output),
404 Some("Win32TokenHere==".into())
405 );
406 }
407
408 #[test]
409 fn extract_session_missing_returns_none() {
410 let output = "Error: master password is incorrect.";
411 assert!(extract_session_token(output).is_none());
412 }
413
414 #[test]
421 fn bitwarden_auth_obtains_token() {
422 let mock_unlock_output = r#"
423Logging in to bitwarden.com ...
424You are logged in!
425
426Your vault is now unlocked!
427
428To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
429$ export BW_SESSION="mocked-session-token-abc123=="
430> $env:BW_SESSION="mocked-session-token-abc123=="
431
432NOTE: You can avoid this message the next time by using the `--raw` flag.
433"#;
434 let token = extract_session_token(mock_unlock_output).expect("token should be extracted");
435 assert_eq!(token, "mocked-session-token-abc123==");
436 }
437
438 #[test]
441 fn bitwarden_cipher_type_filter() {
442 let ciphers = vec![
443 make_login_cipher("Login Item", Some("user@example.com"), Some("hunter2")),
444 BwCipher {
445 cipher_type: 2, name: "My Note".to_string(),
447 login: None,
448 fields: vec![],
449 folder_id: None,
450 },
451 BwCipher {
452 cipher_type: 3, name: "My Card".to_string(),
454 login: None,
455 fields: vec![],
456 folder_id: None,
457 },
458 ];
459
460 let pairs = map_ciphers_to_kv(&ciphers);
461 assert!(!pairs.is_empty());
463 for (key, _) in &pairs {
464 assert!(key.starts_with("LOGIN_ITEM_"), "unexpected key: {key}");
465 }
466 }
467
468 #[test]
471 fn bitwarden_field_mapping() {
472 let ciphers = vec![BwCipher {
473 cipher_type: 1,
474 name: "Foo".to_string(),
475 login: Some(BwLogin {
476 username: Some("alice".to_string()),
477 password: Some("s3cr3t".to_string()),
478 }),
479 fields: vec![BwField {
480 name: Some("host".to_string()),
481 value: Some("db.example.com".to_string()),
482 field_type: 0,
483 }],
484 folder_id: None,
485 }];
486
487 let pairs = map_ciphers_to_kv(&ciphers);
488 let map: HashMap<&str, &str> = pairs
489 .iter()
490 .map(|(k, v)| (k.as_str(), v.as_str()))
491 .collect();
492
493 assert_eq!(
494 map.get("FOO_USERNAME"),
495 Some(&"alice"),
496 "username key missing"
497 );
498 assert_eq!(
499 map.get("FOO_PASSWORD"),
500 Some(&"s3cr3t"),
501 "password key missing"
502 );
503 assert_eq!(
504 map.get("FOO_HOST"),
505 Some(&"db.example.com"),
506 "host field key missing"
507 );
508 }
509
510 #[test]
513 fn bitwarden_empty_fields_skipped() {
514 let ciphers = vec![BwCipher {
515 cipher_type: 1,
516 name: "Partial Item".to_string(),
517 login: Some(BwLogin {
518 username: Some("".to_string()), password: Some("valid-pw".to_string()),
520 }),
521 fields: vec![
522 BwField {
523 name: Some("empty-field".to_string()),
524 value: Some("".to_string()), field_type: 0,
526 },
527 BwField {
528 name: Some("boolean-flag".to_string()),
529 value: Some("true".to_string()),
530 field_type: 2, },
532 BwField {
533 name: Some("api-key".to_string()),
534 value: Some("abc-123".to_string()),
535 field_type: 0,
536 },
537 ],
538 folder_id: None,
539 }];
540
541 let pairs = map_ciphers_to_kv(&ciphers);
542 let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
543
544 assert!(
546 keys.contains(&"PARTIAL_ITEM_PASSWORD"),
547 "password key missing"
548 );
549 assert!(
550 keys.contains(&"PARTIAL_ITEM_API_KEY"),
551 "api-key field missing"
552 );
553
554 assert!(
556 !keys.contains(&"PARTIAL_ITEM_USERNAME"),
557 "empty username should be skipped"
558 );
559 assert!(
561 !keys.contains(&"PARTIAL_ITEM_EMPTY_FIELD"),
562 "empty field value should be skipped"
563 );
564 assert!(
566 !keys.contains(&"PARTIAL_ITEM_BOOLEAN_FLAG"),
567 "boolean field should be skipped"
568 );
569 }
570
571 #[test]
574 fn parse_bw_list_items_json_valid() {
575 let json = r#"[
576 {
577 "type": 1,
578 "name": "My Service",
579 "login": {"username": "svc@example.com", "password": "pw123"},
580 "fields": [],
581 "folderId": null
582 }
583 ]"#;
584 let ciphers: Vec<BwCipher> = serde_json::from_str(json).unwrap();
585 assert_eq!(ciphers.len(), 1);
586 assert_eq!(ciphers[0].name, "My Service");
587 }
588
589 #[test]
590 fn parse_bw_list_items_json_invalid_returns_error() {
591 let json = "not valid json {{{";
592 let err = serde_json::from_str::<Vec<BwCipher>>(json)
593 .map(|_| ())
594 .unwrap_err();
595 assert!(!err.to_string().is_empty());
596 }
597
598 #[test]
606 fn bitwarden_bw_session_not_in_args_structurally() {
607 let list_args: Vec<&str> = vec!["list", "items"];
610 let token = "SUPER_SECRET_SESSION_TOKEN";
611
612 for arg in &list_args {
614 assert_ne!(*arg, token, "BW_SESSION token must not appear in CLI args");
615 }
616 }
621}