use std::collections::HashMap;
use serde::Deserialize;
use crate::config::BitwConfig;
use crate::error::BitwError;
#[derive(Debug, Deserialize)]
pub struct BwCipher {
#[serde(rename = "type")]
pub cipher_type: u8,
pub name: String,
pub login: Option<BwLogin>,
#[serde(default)]
pub fields: Vec<BwField>,
#[serde(rename = "folderId")]
pub folder_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BwLogin {
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BwField {
pub name: Option<String>,
pub value: Option<String>,
#[serde(rename = "type")]
pub field_type: u8,
}
pub fn normalize_item_name(name: &str) -> String {
name.replace([' ', '-', '/'], "_").to_uppercase()
}
pub fn build_key(item_prefix: &str, suffix: &str) -> String {
let norm_suffix = suffix.replace([' ', '-', '/'], "_").to_uppercase();
format!("{item_prefix}_{norm_suffix}")
}
fn extract_session_token(output: &str) -> Option<String> {
for line in output.lines() {
if let Some(rest) = line
.find("BW_SESSION=")
.map(|i| &line[i + "BW_SESSION=".len()..])
{
let token = rest.trim().trim_matches('"').trim_matches('\'').to_string();
if !token.is_empty() {
return Some(token);
}
}
}
None
}
pub fn pull_items(
cfg: &BitwConfig,
password_env: &str,
folder_id: Option<&str>,
) -> Result<Vec<(String, String)>, BitwError> {
if std::env::var(password_env)
.ok()
.filter(|v| !v.is_empty())
.is_none()
{
return Err(BitwError::Config(format!(
"env var `{password_env}` is not set or is empty — \
it must contain the Bitwarden master password for `bw unlock`"
)));
}
let default_identity = BitwConfig::default_identity_url();
if cfg.identity_url != default_identity {
let server_url = cfg
.identity_url
.trim_end_matches('/')
.trim_end_matches("/identity");
run_bw(&["config", "server", server_url], None, None)?;
}
let login_output = run_bw(
&[
"login",
"--apikey",
"--clientid",
&cfg.client_id,
"--clientsecret",
&cfg.client_secret,
],
None,
None,
)?;
tracing::debug!(bytes = login_output.len(), "bw login completed");
let unlock_output =
run_bw(&["unlock", "--passwordenv", password_env], None, None).map_err(|e| match e {
BitwError::ListFailed { status, stderr } => BitwError::UnlockFailed { status, stderr },
other => other,
})?;
let session_token =
extract_session_token(&unlock_output).ok_or(BitwError::SessionTokenMissing)?;
tracing::debug!("bw unlock succeeded, session token obtained");
let folderid_owned: String = folder_id.unwrap_or("").to_string();
let list_args: Vec<&str> = if folder_id.is_some() {
vec!["list", "items", "--folderid", &folderid_owned]
} else {
vec!["list", "items"]
};
let list_json = run_bw(&list_args, Some(&session_token), None)?;
if let Err(e) = run_bw(&["lock"], Some(&session_token), None) {
tracing::warn!("bw lock failed (non-fatal): {e}");
}
let ciphers: Vec<BwCipher> =
serde_json::from_str(&list_json).map_err(|e| BitwError::ParseError(e.to_string()))?;
Ok(map_ciphers_to_kv(&ciphers))
}
fn run_bw(
args: &[&str],
session_token: Option<&str>,
extra_env: Option<&HashMap<String, String>>,
) -> Result<String, BitwError> {
let mut cmd = std::process::Command::new("bw");
cmd.args(args);
cmd.env_remove("BW_SESSION");
if let Some(tok) = session_token {
cmd.env("BW_SESSION", tok);
}
if let Some(env) = extra_env {
for (k, v) in env {
cmd.env(k, v);
}
}
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
BitwError::CliNotFound
} else {
BitwError::ListFailed {
status: -1,
stderr: e.to_string(),
}
}
})?;
if !output.status.success() {
let status = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(BitwError::ListFailed { status, stderr });
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn map_ciphers_to_kv(ciphers: &[BwCipher]) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for cipher in ciphers {
if cipher.cipher_type != 1 {
continue; }
let prefix = normalize_item_name(&cipher.name);
if let Some(login) = &cipher.login {
if let Some(username) = login.username.as_deref().filter(|s| !s.is_empty()) {
pairs.push((build_key(&prefix, "USERNAME"), username.to_string()));
}
if let Some(password) = login.password.as_deref().filter(|s| !s.is_empty()) {
pairs.push((build_key(&prefix, "PASSWORD"), password.to_string()));
}
}
for field in &cipher.fields {
if field.field_type == 2 {
continue; }
let label = match field.name.as_deref().filter(|s| !s.is_empty()) {
Some(l) => l,
None => continue,
};
let value = match field.value.as_deref().filter(|s| !s.is_empty()) {
Some(v) => v,
None => continue,
};
pairs.push((build_key(&prefix, label), value.to_string()));
}
}
pairs
}
#[cfg(test)]
mod tests {
use super::*;
fn make_login_cipher(name: &str, username: Option<&str>, password: Option<&str>) -> BwCipher {
BwCipher {
cipher_type: 1,
name: name.to_string(),
login: Some(BwLogin {
username: username.map(|s| s.to_string()),
password: password.map(|s| s.to_string()),
}),
fields: vec![],
folder_id: None,
}
}
#[test]
fn normalize_spaces_to_underscore() {
assert_eq!(normalize_item_name("Database Creds"), "DATABASE_CREDS");
}
#[test]
fn normalize_hyphens_to_underscore() {
assert_eq!(normalize_item_name("my-api-key"), "MY_API_KEY");
}
#[test]
fn normalize_slash_to_underscore() {
assert_eq!(normalize_item_name("prod/db"), "PROD_DB");
}
#[test]
fn normalize_already_upper() {
assert_eq!(normalize_item_name("FOO_BAR"), "FOO_BAR");
}
#[test]
fn build_key_username() {
assert_eq!(
build_key("DATABASE_CREDS", "USERNAME"),
"DATABASE_CREDS_USERNAME"
);
}
#[test]
fn build_key_custom_field_normalises_suffix() {
assert_eq!(build_key("MY_APP", "host name"), "MY_APP_HOST_NAME");
}
#[test]
fn extract_session_unix_export_format() {
let output = r#"Your vault is now unlocked!
To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="AbCdEfGhIjKlMn=="
> $env:BW_SESSION="AbCdEfGhIjKlMn=="
"#;
assert_eq!(
extract_session_token(output),
Some("AbCdEfGhIjKlMn==".into())
);
}
#[test]
fn extract_session_windows_format() {
let output = r#"Your vault is now unlocked!
> $env:BW_SESSION="Win32TokenHere=="
"#;
assert_eq!(
extract_session_token(output),
Some("Win32TokenHere==".into())
);
}
#[test]
fn extract_session_missing_returns_none() {
let output = "Error: master password is incorrect.";
assert!(extract_session_token(output).is_none());
}
#[test]
fn bitwarden_auth_obtains_token() {
let mock_unlock_output = r#"
Logging in to bitwarden.com ...
You are logged in!
Your vault is now unlocked!
To unlock your vault, set your session key in the `BW_SESSION` environment variable. ex:
$ export BW_SESSION="mocked-session-token-abc123=="
> $env:BW_SESSION="mocked-session-token-abc123=="
NOTE: You can avoid this message the next time by using the `--raw` flag.
"#;
let token = extract_session_token(mock_unlock_output).expect("token should be extracted");
assert_eq!(token, "mocked-session-token-abc123==");
}
#[test]
fn bitwarden_cipher_type_filter() {
let ciphers = vec![
make_login_cipher("Login Item", Some("user@example.com"), Some("hunter2")),
BwCipher {
cipher_type: 2, name: "My Note".to_string(),
login: None,
fields: vec![],
folder_id: None,
},
BwCipher {
cipher_type: 3, name: "My Card".to_string(),
login: None,
fields: vec![],
folder_id: None,
},
];
let pairs = map_ciphers_to_kv(&ciphers);
assert!(!pairs.is_empty());
for (key, _) in &pairs {
assert!(key.starts_with("LOGIN_ITEM_"), "unexpected key: {key}");
}
}
#[test]
fn bitwarden_field_mapping() {
let ciphers = vec![BwCipher {
cipher_type: 1,
name: "Foo".to_string(),
login: Some(BwLogin {
username: Some("alice".to_string()),
password: Some("s3cr3t".to_string()),
}),
fields: vec![BwField {
name: Some("host".to_string()),
value: Some("db.example.com".to_string()),
field_type: 0,
}],
folder_id: None,
}];
let pairs = map_ciphers_to_kv(&ciphers);
let map: HashMap<&str, &str> = pairs
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
map.get("FOO_USERNAME"),
Some(&"alice"),
"username key missing"
);
assert_eq!(
map.get("FOO_PASSWORD"),
Some(&"s3cr3t"),
"password key missing"
);
assert_eq!(
map.get("FOO_HOST"),
Some(&"db.example.com"),
"host field key missing"
);
}
#[test]
fn bitwarden_empty_fields_skipped() {
let ciphers = vec![BwCipher {
cipher_type: 1,
name: "Partial Item".to_string(),
login: Some(BwLogin {
username: Some("".to_string()), password: Some("valid-pw".to_string()),
}),
fields: vec![
BwField {
name: Some("empty-field".to_string()),
value: Some("".to_string()), field_type: 0,
},
BwField {
name: Some("boolean-flag".to_string()),
value: Some("true".to_string()),
field_type: 2, },
BwField {
name: Some("api-key".to_string()),
value: Some("abc-123".to_string()),
field_type: 0,
},
],
folder_id: None,
}];
let pairs = map_ciphers_to_kv(&ciphers);
let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
assert!(
keys.contains(&"PARTIAL_ITEM_PASSWORD"),
"password key missing"
);
assert!(
keys.contains(&"PARTIAL_ITEM_API_KEY"),
"api-key field missing"
);
assert!(
!keys.contains(&"PARTIAL_ITEM_USERNAME"),
"empty username should be skipped"
);
assert!(
!keys.contains(&"PARTIAL_ITEM_EMPTY_FIELD"),
"empty field value should be skipped"
);
assert!(
!keys.contains(&"PARTIAL_ITEM_BOOLEAN_FLAG"),
"boolean field should be skipped"
);
}
#[test]
fn parse_bw_list_items_json_valid() {
let json = r#"[
{
"type": 1,
"name": "My Service",
"login": {"username": "svc@example.com", "password": "pw123"},
"fields": [],
"folderId": null
}
]"#;
let ciphers: Vec<BwCipher> = serde_json::from_str(json).unwrap();
assert_eq!(ciphers.len(), 1);
assert_eq!(ciphers[0].name, "My Service");
}
#[test]
fn parse_bw_list_items_json_invalid_returns_error() {
let json = "not valid json {{{";
let err = serde_json::from_str::<Vec<BwCipher>>(json)
.map(|_| ())
.unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn bitwarden_bw_session_not_in_args_structurally() {
let list_args: Vec<&str> = vec!["list", "items"];
let token = "SUPER_SECRET_SESSION_TOKEN";
for arg in &list_args {
assert_ne!(*arg, token, "BW_SESSION token must not appear in CLI args");
}
}
}