use crate::provider::{Address, DiscoveryContext, Provider, ProviderCredentials, ProviderUrl};
use crate::{Result, Secret, SecretSpecError};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum BitwardenItemType {
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5,
}
impl BitwardenItemType {
pub fn from_u8(value: u8) -> Option<Self> {
match value {
1 => Some(BitwardenItemType::Login),
2 => Some(BitwardenItemType::SecureNote),
3 => Some(BitwardenItemType::Card),
4 => Some(BitwardenItemType::Identity),
5 => Some(BitwardenItemType::SshKey),
_ => None,
}
}
pub fn to_u8(self) -> u8 {
self as u8
}
pub fn default_field(&self) -> &'static str {
match self {
BitwardenItemType::Login => "password",
BitwardenItemType::SecureNote => "value",
BitwardenItemType::Card => "number",
BitwardenItemType::Identity => "email",
BitwardenItemType::SshKey => "private_key",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"login" => Some(BitwardenItemType::Login),
"securenote" | "note" | "secure_note" => Some(BitwardenItemType::SecureNote),
"card" => Some(BitwardenItemType::Card),
"identity" => Some(BitwardenItemType::Identity),
"sshkey" | "ssh_key" | "ssh" => Some(BitwardenItemType::SshKey),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
BitwardenItemType::Login => "login",
BitwardenItemType::SecureNote => "securenote",
BitwardenItemType::Card => "card",
BitwardenItemType::Identity => "identity",
BitwardenItemType::SshKey => "sshkey",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum BitwardenFieldType {
Text = 0,
Hidden = 1,
Boolean = 2,
Linked = 3,
}
impl BitwardenFieldType {
pub fn from_u8(value: u8) -> Option<Self> {
match value {
0 => Some(BitwardenFieldType::Text),
1 => Some(BitwardenFieldType::Hidden),
2 => Some(BitwardenFieldType::Boolean),
3 => Some(BitwardenFieldType::Linked),
_ => None,
}
}
pub fn to_u8(self) -> u8 {
self as u8
}
pub fn for_field_name(field_name: &str) -> Self {
let name_lower = field_name.to_lowercase();
if name_lower.contains("password")
|| name_lower.contains("secret")
|| name_lower.contains("token")
|| name_lower.contains("key")
|| name_lower.contains("value")
|| name_lower.contains("code")
|| name_lower.contains("cvv")
|| name_lower.contains("cvc")
{
BitwardenFieldType::Hidden
} else {
BitwardenFieldType::Text
}
}
#[allow(dead_code)]
pub fn as_str(&self) -> &'static str {
match self {
BitwardenFieldType::Text => "text",
BitwardenFieldType::Hidden => "hidden",
BitwardenFieldType::Boolean => "boolean",
BitwardenFieldType::Linked => "linked",
}
}
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct BitwardenItem {
id: String,
name: String,
#[serde(rename = "type", deserialize_with = "deserialize_item_type")]
item_type: BitwardenItemType,
fields: Option<Vec<BitwardenField>>,
notes: Option<String>,
login: Option<BitwardenLogin>,
card: Option<BitwardenCard>,
identity: Option<BitwardenIdentity>,
#[serde(rename = "sshKey")]
ssh_key: Option<BitwardenSshKey>,
object: Option<String>,
#[serde(rename = "organizationId")]
organization_id: Option<String>,
#[serde(rename = "collectionIds")]
collection_ids: Option<Vec<String>>,
#[serde(rename = "folderId")]
folder_id: Option<String>,
favorite: Option<bool>,
reprompt: Option<u8>,
#[serde(rename = "passwordHistory")]
password_history: Option<Vec<serde_json::Value>>,
#[serde(rename = "creationDate")]
creation_date: Option<String>,
#[serde(rename = "revisionDate")]
revision_date: Option<String>,
#[serde(rename = "deletedDate")]
deleted_date: Option<String>,
}
fn deserialize_item_type<'de, D>(
deserializer: D,
) -> std::result::Result<BitwardenItemType, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
BitwardenItemType::from_u8(value)
.ok_or_else(|| serde::de::Error::custom(format!("Unknown item type: {}", value)))
}
#[derive(Debug, Serialize, Deserialize)]
struct BitwardenLogin {
username: Option<String>,
password: Option<String>,
totp: Option<String>,
uris: Option<Vec<BitwardenUri>>,
#[serde(rename = "passwordRevisionDate")]
password_revision_date: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BitwardenUri {
uri: Option<String>,
#[serde(rename = "match")]
match_type: Option<u8>,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(dead_code)]
struct BitwardenCard {
#[serde(rename = "cardholderName")]
cardholder_name: Option<String>,
number: Option<String>,
brand: Option<String>,
#[serde(rename = "expMonth")]
exp_month: Option<String>,
#[serde(rename = "expYear")]
exp_year: Option<String>,
code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BitwardenIdentity {
title: Option<String>,
#[serde(rename = "firstName")]
first_name: Option<String>,
#[serde(rename = "middleName")]
middle_name: Option<String>,
#[serde(rename = "lastName")]
last_name: Option<String>,
username: Option<String>,
company: Option<String>,
email: Option<String>,
phone: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct BitwardenSshKey {
#[serde(rename = "privateKey")]
private_key: Option<String>,
#[serde(rename = "publicKey")]
public_key: Option<String>,
#[serde(rename = "keyFingerprint")]
key_fingerprint: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct BitwardenField {
name: Option<String>,
value: Option<String>,
#[serde(rename = "type", deserialize_with = "deserialize_field_type")]
field_type: BitwardenFieldType,
#[serde(rename = "linkedId", default)]
linked_id: Option<serde_json::Value>,
}
fn deserialize_field_type<'de, D>(
deserializer: D,
) -> std::result::Result<BitwardenFieldType, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = u8::deserialize(deserializer)?;
BitwardenFieldType::from_u8(value)
.ok_or_else(|| serde::de::Error::custom(format!("Unknown field type: {}", value)))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BitwardenConfig {
pub organization_id: Option<String>,
pub collection_id: Option<String>,
pub server: Option<String>,
pub folder_prefix: Option<String>,
pub default_item_type: Option<BitwardenItemType>,
pub default_field: Option<String>,
}
impl TryFrom<&ProviderUrl> for BitwardenConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
if scheme != "bw" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for Bitwarden provider. Use 'bw://' for Password Manager",
scheme
)));
}
let mut config = BitwardenConfig::default();
if let Some(host) = url.host()
&& host != "localhost"
{
if !url.username().is_empty() {
config.organization_id = Some(url.username());
config.collection_id = Some(host);
} else {
config.collection_id = Some(host);
}
}
for (key, value) in url.query_pairs() {
match key.as_ref() {
"org" | "organization" => config.organization_id = Some(value.into_owned()),
"collection" => config.collection_id = Some(value.into_owned()),
"server" => config.server = Some(value.into_owned()),
"folder" => config.folder_prefix = Some(value.into_owned()),
"type" => config.default_item_type = Some(parse_item_type(&value, "?type=")?),
"field" => config.default_field = Some(value.into_owned()),
unknown => {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Unknown Bitwarden URI parameter '{unknown}'. Valid parameters are \
org (or organization), collection, server, folder, type, and field."
)));
}
}
}
Ok(config)
}
}
pub struct BitwardenProvider {
config: BitwardenConfig,
credentials: ProviderCredentials,
server_check: OnceLock<std::result::Result<(), String>>,
vault_scope: OnceLock<std::result::Result<VaultScope, String>>,
#[cfg(test)]
cli_binary_path: std::path::PathBuf,
}
const BITWARDEN_CLOUD_SERVER: &str = "https://vault.bitwarden.com";
const SSH_KEY_FIELD_UNSET: &str = "(not set by SecretSpec)";
fn parse_status_server(stdout: &str) -> std::result::Result<Option<String>, String> {
let status: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
format!(
"could not parse `bw status` output as JSON: {}",
crate::error::display_error_chain(&e)
)
})?;
match status.get("serverUrl") {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())),
Some(other) => Err(format!(
"unexpected `serverUrl` type in `bw status` output: {other}"
)),
}
}
fn normalize_server(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches('/');
match url::Url::parse(trimmed) {
Ok(url) => {
let mut out = format!("{}://{}", url.scheme(), url.host_str().unwrap_or_default());
if let Some(port) = url.port() {
out.push_str(&format!(":{port}"));
}
out.push_str(url.path().trim_end_matches('/'));
out
}
Err(_) => trimmed.to_ascii_lowercase(),
}
}
fn servers_match(expected: &str, current: &str) -> bool {
normalize_server(expected) == normalize_server(current)
}
#[derive(Debug, Deserialize)]
struct BitwardenNamedObject {
id: String,
name: String,
#[serde(rename = "organizationId", default)]
organization_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct VaultScope {
organization_id: Option<String>,
collection_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ItemPlacement {
organization_id: Option<String>,
collection_ids: Option<Vec<String>>,
}
impl From<&VaultScope> for ItemPlacement {
fn from(scope: &VaultScope) -> Self {
Self {
organization_id: scope.organization_id.clone(),
collection_ids: scope.collection_id.clone().map(|id| vec![id]),
}
}
}
fn parse_named_objects(
json: &str,
kind: &str,
) -> std::result::Result<Vec<BitwardenNamedObject>, String> {
let trimmed = json.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(trimmed).map_err(|e| {
format!(
"could not parse `bw list {kind}` output as JSON: {}",
crate::error::display_error_chain(&e)
)
})
}
fn describe_org(id: Option<&str>, organizations: &[BitwardenNamedObject]) -> String {
match id {
None => "your personal vault".to_string(),
Some(id) => match organizations.iter().find(|o| o.id == id) {
Some(org) => format!("'{}' ({id})", org.name),
None => format!("'{id}'"),
},
}
}
fn list_organizations(organizations: &[BitwardenNamedObject]) -> String {
if organizations.is_empty() {
return "The bw CLI listed no organizations. If you do belong to one, the \
CLI cannot currently read it: check that BW_SESSION is exported \
and current, then run `bw sync --force`."
.to_string();
}
let mut out = String::from("Available organizations:");
for org in organizations {
out.push_str(&format!("\n - {} ({})", org.name, org.id));
}
out
}
fn list_collections(
collections: &[&BitwardenNamedObject],
organizations: &[BitwardenNamedObject],
) -> String {
if collections.is_empty() {
return "The bw CLI listed no collections. If you expected some, the CLI \
cannot currently read them: check that BW_SESSION is exported and \
current, then run `bw sync --force`."
.to_string();
}
let mut out = String::from("Available collections:");
for collection in collections {
out.push_str(&format!(
"\n - {} ({}) — organization {}",
collection.name,
collection.id,
describe_org(collection.organization_id.as_deref(), organizations)
));
}
out
}
fn resolve_organization<'a>(
organizations: &'a [BitwardenNamedObject],
requested: &str,
) -> std::result::Result<&'a BitwardenNamedObject, String> {
if let Some(hit) = organizations.iter().find(|o| o.id == requested) {
return Ok(hit);
}
let matches: Vec<&BitwardenNamedObject> = organizations
.iter()
.filter(|o| o.name.to_lowercase() == requested.to_lowercase())
.collect();
match matches.as_slice() {
[only] => Ok(only),
[] => Err(format!(
"No organization matching '{requested}' is visible to the bw CLI.\n\n{}\n\n\
An organization is addressed by name or by UUID. Run `bw sync` if it was \
created or shared with you recently.",
list_organizations(organizations)
)),
multiple => Err(format!(
"Organization name '{requested}' is ambiguous: {} organizations share it. \
Use the organization's UUID instead.\n\n{}",
multiple.len(),
list_organizations(organizations)
)),
}
}
fn check_collection_org<'a>(
collection: &'a BitwardenNamedObject,
organizations: &[BitwardenNamedObject],
org: Option<&BitwardenNamedObject>,
) -> std::result::Result<&'a BitwardenNamedObject, String> {
let Some(org) = org else {
return Ok(collection);
};
if collection.organization_id.as_deref() == Some(org.id.as_str()) {
return Ok(collection);
}
Err(format!(
"Collection '{}' ({}) belongs to organization {}, but the address names {}.\n\n\
A collection id already identifies its organization, so drop the organization \
from the address or correct it.",
collection.name,
collection.id,
describe_org(collection.organization_id.as_deref(), organizations),
describe_org(Some(org.id.as_str()), organizations),
))
}
fn resolve_collection<'a>(
collections: &'a [BitwardenNamedObject],
organizations: &[BitwardenNamedObject],
requested: &str,
org: Option<&BitwardenNamedObject>,
) -> std::result::Result<&'a BitwardenNamedObject, String> {
if let Some(hit) = collections.iter().find(|c| c.id == requested) {
return check_collection_org(hit, organizations, org);
}
let by_name: Vec<&BitwardenNamedObject> = collections
.iter()
.filter(|c| c.name.to_lowercase() == requested.to_lowercase())
.collect();
let scoped: Vec<&BitwardenNamedObject> = match org {
Some(org) => by_name
.iter()
.copied()
.filter(|c| c.organization_id.as_deref() == Some(org.id.as_str()))
.collect(),
None => by_name.clone(),
};
match scoped.as_slice() {
[only] => Ok(only),
[] if !by_name.is_empty() => {
let org = org.expect("names are only narrowed when the address gave an organization");
Err(format!(
"Collection '{requested}' is not in organization {}. It exists in {}.\n\n\
Correct the organization in the address, or drop it and address the \
collection on its own.",
describe_org(Some(org.id.as_str()), organizations),
by_name
.iter()
.map(|c| describe_org(c.organization_id.as_deref(), organizations))
.collect::<Vec<_>>()
.join(", ")
))
}
[] => {
let visible: Vec<&BitwardenNamedObject> = match org {
Some(org) => collections
.iter()
.filter(|c| c.organization_id.as_deref() == Some(org.id.as_str()))
.collect(),
None => collections.iter().collect(),
};
let scope_note = match org {
Some(org) => format!(" in organization '{}'", org.name),
None => String::new(),
};
Err(format!(
"No collection matching '{requested}' is visible to the bw CLI{scope_note}.\n\n{}\n\n\
A collection is addressed by name or by UUID. Run `bw sync` if it was \
created or shared with you recently.",
list_collections(&visible, organizations)
))
}
multiple => Err(format!(
"Collection name '{requested}' is ambiguous: {} collections share it.\n\n{}\n\n\
Qualify it with an organization, for example bw://{}@{requested}, or use \
the collection's UUID.",
multiple.len(),
list_collections(multiple, organizations),
multiple
.first()
.and_then(|c| c.organization_id.as_deref())
.and_then(|id| organizations.iter().find(|o| o.id == id))
.map(|o| o.name.as_str())
.unwrap_or("myorg")
)),
}
}
fn resolve_scope(
organizations_json: &str,
collections_json: &str,
requested_org: Option<&str>,
requested_collection: Option<&str>,
) -> std::result::Result<VaultScope, String> {
if requested_org.is_none() && requested_collection.is_none() {
return Ok(VaultScope::default());
}
let organizations = parse_named_objects(organizations_json, "organizations")?;
let collections = parse_named_objects(collections_json, "collections")?;
let org = requested_org
.map(|requested| resolve_organization(&organizations, requested))
.transpose()?;
let Some(requested_collection) = requested_collection else {
return Ok(VaultScope {
organization_id: org.map(|o| o.id.clone()),
collection_id: None,
});
};
let collection = resolve_collection(&collections, &organizations, requested_collection, org)?;
Ok(VaultScope {
organization_id: collection
.organization_id
.clone()
.or_else(|| org.map(|o| o.id.clone())),
collection_id: Some(collection.id.clone()),
})
}
fn parse_item_type(value: &str, source: &str) -> Result<BitwardenItemType> {
BitwardenItemType::from_str(value).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(format!(
"Unknown Bitwarden item type '{value}' in {source}. Valid types are \
login, note (or securenote, secure_note), card, identity, and ssh \
(or sshkey, ssh_key)."
))
})
}
fn builtin_member(item_type: BitwardenItemType, field: &str) -> Option<&'static str> {
let field = field.to_lowercase();
let member = match item_type {
BitwardenItemType::Login => match field.as_str() {
"password" => "password",
"username" => "username",
"totp" => "totp",
_ => return None,
},
BitwardenItemType::Card => match field.as_str() {
"number" => "number",
"code" | "cvv" | "cvc" => "code",
"cardholder" | "name" => "cardholderName",
"brand" => "brand",
"expmonth" | "exp_month" => "expMonth",
"expyear" | "exp_year" => "expYear",
_ => return None,
},
BitwardenItemType::Identity => match field.as_str() {
"email" => "email",
"username" => "username",
"phone" => "phone",
"firstname" | "first_name" => "firstName",
"lastname" | "last_name" => "lastName",
"company" => "company",
_ => return None,
},
BitwardenItemType::SshKey => match field.as_str() {
"private_key" | "privatekey" | "private" => "privateKey",
"public_key" | "publickey" | "public" => "publicKey",
"fingerprint" | "key_fingerprint" => "keyFingerprint",
_ => return None,
},
BitwardenItemType::SecureNote => return None,
};
Some(member)
}
fn bw_command_failed(operation: &str, output: &std::process::Output) -> SecretSpecError {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = match stderr.trim() {
"" => {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim().to_string()
}
message => message.to_string(),
};
let status = match output.status.code() {
Some(code) => format!("exit status {code}"),
None => "terminated by a signal".to_string(),
};
if detail.is_empty() {
SecretSpecError::ProviderOperationFailed(format!("`{operation}` failed ({status})"))
} else {
SecretSpecError::ProviderOperationFailed(format!(
"`{operation}` failed ({status}): {detail}"
))
}
}
fn is_note_body_field(field: &str) -> bool {
field.eq_ignore_ascii_case("notes")
}
fn find_addressed_item<'a>(
items: &'a [BitwardenItem],
item_name: &str,
require_type: Option<BitwardenItemType>,
) -> Result<Option<&'a BitwardenItem>> {
let wanted = item_name.to_lowercase();
let by_name: Vec<&BitwardenItem> = items
.iter()
.filter(|item| item.name.to_lowercase() == wanted)
.collect();
let candidates: Vec<&BitwardenItem> = match require_type {
Some(wanted_type) => by_name
.into_iter()
.filter(|item| item.item_type == wanted_type)
.collect(),
None => by_name,
};
match candidates.as_slice() {
[] => Ok(None),
[only] => Ok(Some(only)),
several => Err(SecretSpecError::ProviderOperationFailed(format!(
"{} Bitwarden items are named '{item_name}'. Rename them, or point the \
secret at one of these ids with ref = {{ item = \"<id>\" }}:\n{}",
several.len(),
several
.iter()
.map(|item| format!(" {} ({:?})", item.id, item.item_type))
.collect::<Vec<_>>()
.join("\n"),
))),
}
}
fn declarations_from_items(
items: &[BitwardenItem],
required_type: Option<BitwardenItemType>,
) -> Result<HashMap<String, Secret>> {
let mut declarations = HashMap::new();
let mut seen_names = HashMap::<String, &BitwardenItem>::new();
for item in items
.iter()
.filter(|item| required_type.is_none_or(|item_type| item.item_type == item_type))
{
if !crate::config::is_valid_identifier(&item.name) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Bitwarden item '{}' cannot become a SecretSpec declaration: names must be \
alphanumeric and underscores and cannot start with a number. Rename the item \
or narrow discovery with a collection and/or `?type=`.",
item.name
)));
}
if item.name == "defaults" {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden item 'defaults' cannot become a SecretSpec declaration because \
that name is reserved for profile defaults. Rename the item or narrow \
discovery with a collection and/or `?type=`."
.to_string(),
));
}
let folded_name = item.name.to_lowercase();
if let Some(previous) = seen_names.insert(folded_name, item) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Bitwarden items '{}' ({}) and '{}' ({}) have names that collide \
case-insensitively. Rename one or narrow discovery with `?type=` so every \
reflected declaration has one address.",
previous.name, previous.id, item.name, item.id
)));
}
declarations.insert(
item.name.clone(),
Secret {
description: Some(format!("{} Bitwarden secret", item.name)),
required: Some(true),
..Default::default()
},
);
}
Ok(declarations)
}
crate::register_provider! {
struct: BitwardenProvider,
config: BitwardenConfig,
name: "bw",
description: "Bitwarden Password Manager",
schemes: ["bw"],
examples: [
"bw://",
"bw://collection-id",
"bw://org@collection"
],
}
impl BitwardenProvider {
pub fn new(config: BitwardenConfig) -> Self {
Self {
config,
credentials: ProviderCredentials::new(),
server_check: OnceLock::new(),
vault_scope: OnceLock::new(),
#[cfg(test)]
cli_binary_path: "bw".into(),
}
}
fn command(&self) -> Command {
#[cfg(test)]
return Command::new(&self.cli_binary_path);
#[cfg(not(test))]
Command::new("bw")
}
fn requested_org(&self) -> Option<String> {
std::env::var("BITWARDEN_ORGANIZATION")
.ok()
.or_else(|| self.config.organization_id.clone())
}
fn requested_collection(&self) -> Option<String> {
std::env::var("BITWARDEN_COLLECTION")
.ok()
.or_else(|| self.config.collection_id.clone())
}
fn resolved_scope(&self) -> Result<&VaultScope> {
match self.vault_scope.get_or_init(|| self.look_up_scope()) {
Ok(scope) => Ok(scope),
Err(message) => Err(SecretSpecError::ProviderOperationFailed(message.clone())),
}
}
fn resolved_org_id(&self) -> Result<Option<&str>> {
Ok(self.resolved_scope()?.organization_id.as_deref())
}
fn look_up_scope(&self) -> std::result::Result<VaultScope, String> {
let requested_org = self.requested_org();
let requested_collection = self.requested_collection();
if requested_org.is_none() && requested_collection.is_none() {
return Ok(VaultScope::default());
}
let organizations = self
.execute_bw_command(&["list", "organizations"])
.map_err(|e| {
format!(
"could not list Bitwarden organizations: {}",
crate::error::display_error_chain(&e)
)
})?;
let collections = self
.execute_bw_command(&["list", "collections"])
.map_err(|e| {
format!(
"could not list Bitwarden collections: {}",
crate::error::display_error_chain(&e)
)
})?;
resolve_scope(
&organizations,
&collections,
requested_org.as_deref(),
requested_collection.as_deref(),
)
}
fn search_filter_args(&self) -> Result<Vec<String>> {
let scope = self.resolved_scope()?;
if let Some(collection_id) = scope.collection_id.as_deref() {
return Ok(vec![
"--collectionid".to_string(),
collection_id.to_string(),
]);
}
if let Some(org_id) = scope.organization_id.as_deref() {
return Ok(vec!["--organizationid".to_string(), org_id.to_string()]);
}
Ok(Vec::new())
}
fn resolved_item_type(&self) -> Result<Option<BitwardenItemType>> {
match std::env::var("BITWARDEN_DEFAULT_TYPE") {
Ok(raw) => parse_item_type(&raw, "BITWARDEN_DEFAULT_TYPE").map(Some),
Err(_) => Ok(self.config.default_item_type),
}
}
fn list_items(&self, search: Option<&str>) -> Result<Vec<BitwardenItem>> {
let mut list_args = vec!["list", "items"];
if let Some(term) = search {
list_args.push("--search");
list_args.push(term);
}
let filter = self.search_filter_args()?;
list_args.extend(filter.iter().map(String::as_str));
let output = self.execute_bw_command(&list_args)?;
Ok(serde_json::from_str(&output)?)
}
fn item_placement(&self) -> Result<ItemPlacement> {
Ok(ItemPlacement::from(self.resolved_scope()?))
}
fn ensure_server_configured(&self) -> Result<()> {
let Some(expected) = self.config.server.as_deref() else {
return Ok(());
};
match self
.server_check
.get_or_init(|| self.check_server(expected))
{
Ok(()) => Ok(()),
Err(message) => Err(SecretSpecError::ProviderOperationFailed(message.clone())),
}
}
fn check_server(&self, expected: &str) -> std::result::Result<(), String> {
let output = match self.command().args(["--nointeraction", "status"]).output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => {
return Err(format!(
"could not run `bw status` to verify the configured server: {e}"
));
}
};
if !output.status.success() {
return Err(format!(
"`bw status` failed while verifying the configured server ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
let reported = parse_status_server(&String::from_utf8_lossy(&output.stdout))?;
let current = reported.as_deref().unwrap_or(BITWARDEN_CLOUD_SERVER);
if servers_match(expected, current) {
return Ok(());
}
let current_description = match reported.as_deref() {
Some(server) => server.to_string(),
None => format!("the public Bitwarden cloud ({BITWARDEN_CLOUD_SERVER})"),
};
Err(format!(
"The bw CLI is configured for {current_description}, but this provider \
expects {expected}.\n\n\
The bw CLI reads its server only from its own configuration, so point \
it at the expected server before retrying:\n\
\n bw logout\
\n bw config server {expected}\
\n bw login\
\n bw unlock\
\n\nThen export BW_SESSION from the unlock output."
))
}
fn execute_bw_command(&self, args: &[&str]) -> Result<String> {
self.ensure_server_configured()?;
let mut cmd = self.command();
cmd.arg("--nointeraction");
cmd.args(args);
let output = match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden CLI (bw) is not installed.\n\nTo install it:\n - npm: npm install -g @bitwarden/cli\n - Homebrew: brew install bitwarden-cli\n - Chocolatey: choco install bitwarden-cli\n - Download: https://bitwarden.com/help/cli/\n\nAfter installation, run 'bw login' and 'bw unlock' to authenticate.".to_string(),
));
}
Err(e) => return Err(e.into()),
};
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
if error_msg.contains("You are not logged in") {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden authentication required. Please run 'bw login' first.".to_string(),
));
}
if error_msg.contains("Vault is locked") {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden vault is locked. Please run 'bw unlock' and set the BW_SESSION environment variable.".to_string(),
));
}
return Err(bw_command_failed(
&format!("bw {}", args.join(" ")),
&output,
));
}
String::from_utf8(output.stdout).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"`bw {}` returned output that is not valid UTF-8: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
})
}
fn is_authenticated(&self) -> Result<bool> {
match self.execute_bw_command(&["status"]) {
Ok(output) => {
let status: serde_json::Value = serde_json::from_str(&output)?;
let status_str = status["status"].as_str().unwrap_or("");
Ok(status_str == "unlocked")
}
Err(SecretSpecError::ProviderOperationFailed(msg))
if {
let lower = msg.to_lowercase();
lower.contains("you are not logged in")
|| lower.contains("vault is locked")
|| lower.contains("please run 'bw login'")
|| lower.contains("please run 'bw unlock'")
} =>
{
Ok(false)
}
Err(e) => Err(e),
}
}
fn get_from_password_manager(
&self,
item_name: &str,
field_hint: Option<&str>,
) -> Result<Option<SecretString>> {
if !self.is_authenticated()? {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden authentication required. Please run 'bw login' and 'bw unlock', then set the BW_SESSION environment variable.".to_string(),
));
}
let mut items = self.list_items(Some(item_name))?;
if items.is_empty() {
items = self.list_items(None)?;
}
if let Some(item) = find_addressed_item(&items, item_name, self.resolved_item_type()?)? {
return self.extract_value_from_item(item, field_hint);
}
Ok(None)
}
fn extract_value_from_item(
&self,
item: &BitwardenItem,
field_hint: Option<&str>,
) -> Result<Option<SecretString>> {
let resolved_field = field_hint
.map(|s| s.to_string())
.or_else(|| std::env::var("BITWARDEN_DEFAULT_FIELD").ok())
.or_else(|| self.config.default_field.clone());
match item.item_type {
BitwardenItemType::Login => {
self.extract_from_login_item(item, resolved_field.as_deref())
}
BitwardenItemType::SecureNote => {
self.extract_from_secure_note_item(item, resolved_field.as_deref())
}
BitwardenItemType::Card => self.extract_from_card_item(item, resolved_field.as_deref()),
BitwardenItemType::Identity => {
self.extract_from_identity_item(item, resolved_field.as_deref())
}
BitwardenItemType::SshKey => {
self.extract_from_ssh_key_item(item, resolved_field.as_deref())
}
}
}
fn extract_from_login_item(
&self,
item: &BitwardenItem,
resolved_field: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(login) = &item.login {
if let Some(field_name) = resolved_field {
match field_name.to_lowercase().as_str() {
"password" => {
return Ok(login
.password
.as_ref()
.map(|p| SecretString::new(p.clone().into())));
}
"username" => {
return Ok(login
.username
.as_ref()
.map(|u| SecretString::new(u.clone().into())));
}
"totp" => {
return Ok(login
.totp
.as_ref()
.map(|t| SecretString::new(t.clone().into())));
}
_ => {
if let Some(value) = self.extract_from_custom_fields(item, field_name)? {
return Ok(Some(SecretString::new(value.into())));
} else {
return Ok(None);
}
}
}
}
if let Some(password) = &login.password {
return Ok(Some(SecretString::new(password.clone().into())));
}
if let Some(username) = &login.username {
return Ok(Some(SecretString::new(username.clone().into())));
}
}
if let Some(value) = self.extract_from_custom_fields(item, "value")? {
Ok(Some(SecretString::new(value.into())))
} else {
Ok(None)
}
}
fn extract_from_secure_note_item(
&self,
item: &BitwardenItem,
resolved_field: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(field_name) = resolved_field {
if is_note_body_field(field_name) {
return Ok(item
.notes
.as_ref()
.map(|notes| SecretString::new(notes.clone().into())));
}
return Ok(self
.extract_from_custom_fields(item, field_name)?
.map(|value| SecretString::new(value.into())));
}
if let Some(value) = self.extract_from_custom_fields(item, "value")? {
return Ok(Some(SecretString::new(value.into())));
}
Ok(item
.notes
.as_ref()
.map(|notes| SecretString::new(notes.clone().into())))
}
fn extract_from_card_item(
&self,
item: &BitwardenItem,
resolved_field: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(card) = &item.card {
if let Some(field_name) = resolved_field {
match field_name.to_lowercase().as_str() {
"number" => {
return Ok(card
.number
.as_ref()
.map(|n| SecretString::new(n.clone().into())));
}
"code" | "cvv" | "cvc" => {
return Ok(card
.code
.as_ref()
.map(|c| SecretString::new(c.clone().into())));
}
"cardholder" | "name" => {
return Ok(card
.cardholder_name
.as_ref()
.map(|n| SecretString::new(n.clone().into())));
}
"brand" => {
return Ok(card
.brand
.as_ref()
.map(|b| SecretString::new(b.clone().into())));
}
"expmonth" | "exp_month" => {
return Ok(card
.exp_month
.as_ref()
.map(|m| SecretString::new(m.clone().into())));
}
"expyear" | "exp_year" => {
return Ok(card
.exp_year
.as_ref()
.map(|y| SecretString::new(y.clone().into())));
}
_ => {
if let Some(value) = self.extract_from_custom_fields(item, field_name)? {
return Ok(Some(SecretString::new(value.into())));
} else {
return Ok(None);
}
}
}
}
if let Some(number) = &card.number {
return Ok(Some(SecretString::new(number.clone().into())));
}
}
if let Some(value) = self.extract_from_custom_fields(item, "value")? {
Ok(Some(SecretString::new(value.into())))
} else {
Ok(None)
}
}
fn extract_from_identity_item(
&self,
item: &BitwardenItem,
resolved_field: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(identity) = &item.identity {
if let Some(field_name) = resolved_field {
match field_name.to_lowercase().as_str() {
"email" => {
return Ok(identity
.email
.as_ref()
.map(|e| SecretString::new(e.clone().into())));
}
"username" => {
return Ok(identity
.username
.as_ref()
.map(|u| SecretString::new(u.clone().into())));
}
"phone" => {
return Ok(identity
.phone
.as_ref()
.map(|p| SecretString::new(p.clone().into())));
}
"firstname" | "first_name" => {
return Ok(identity
.first_name
.as_ref()
.map(|f| SecretString::new(f.clone().into())));
}
"lastname" | "last_name" => {
return Ok(identity
.last_name
.as_ref()
.map(|l| SecretString::new(l.clone().into())));
}
"company" => {
return Ok(identity
.company
.as_ref()
.map(|c| SecretString::new(c.clone().into())));
}
_ => {
if let Some(value) = self.extract_from_custom_fields(item, field_name)? {
return Ok(Some(SecretString::new(value.into())));
} else {
return Ok(None);
}
}
}
}
if let Some(email) = &identity.email {
return Ok(Some(SecretString::new(email.clone().into())));
}
if let Some(username) = &identity.username {
return Ok(Some(SecretString::new(username.clone().into())));
}
}
if let Some(value) = self.extract_from_custom_fields(item, "value")? {
Ok(Some(SecretString::new(value.into())))
} else {
Ok(None)
}
}
fn extract_from_ssh_key_item(
&self,
item: &BitwardenItem,
resolved_field: Option<&str>,
) -> Result<Option<SecretString>> {
if let Some(ssh_key) = &item.ssh_key {
if let Some(field_name) = resolved_field {
match field_name.to_lowercase().as_str() {
"private_key" | "privatekey" | "private" => {
return Ok(ssh_key
.private_key
.as_ref()
.map(|k| SecretString::new(k.clone().into())));
}
"public_key" | "publickey" | "public" => {
return Ok(ssh_key
.public_key
.as_ref()
.map(|k| SecretString::new(k.clone().into())));
}
"fingerprint" | "key_fingerprint" => {
return Ok(ssh_key
.key_fingerprint
.as_ref()
.map(|f| SecretString::new(f.clone().into())));
}
_ => {
if let Some(value) = self.extract_from_custom_fields(item, field_name)? {
return Ok(Some(SecretString::new(value.into())));
} else {
return Ok(None);
}
}
}
}
if let Some(private_key) = &ssh_key.private_key {
return Ok(Some(SecretString::new(private_key.clone().into())));
}
}
if let Some(value) = self.extract_from_custom_fields(item, "value")? {
Ok(Some(SecretString::new(value.into())))
} else {
Ok(None)
}
}
fn extract_from_custom_fields(
&self,
item: &BitwardenItem,
field_name: &str,
) -> Result<Option<String>> {
if let Some(fields) = &item.fields {
for field in fields {
if let Some(name) = &field.name
&& name.eq_ignore_ascii_case(field_name)
{
return Ok(field.value.clone());
}
}
for field in fields {
if let Some(name) = &field.name
&& name.to_lowercase().contains(&field_name.to_lowercase())
{
return Ok(field.value.clone());
}
}
}
Ok(None)
}
fn set_to_password_manager(
&self,
item_name: &str,
target_field: Option<&str>,
value: &SecretString,
) -> Result<()> {
if !self.is_authenticated()? {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden authentication required. Please run 'bw login' and 'bw unlock', then set the BW_SESSION environment variable.".to_string(),
));
}
let items = self.list_items(None)?;
if let Some(item) = find_addressed_item(&items, item_name, self.resolved_item_type()?)? {
return self.update_existing_item(item, target_field, value.expose_secret());
}
self.create_new_item(item_name, target_field, value.expose_secret())
}
fn update_existing_item(
&self,
item: &BitwardenItem,
target_field: Option<&str>,
value: &str,
) -> Result<()> {
let field = target_field
.map(|s| s.to_string())
.or_else(|| std::env::var("BITWARDEN_DEFAULT_FIELD").ok())
.or_else(|| self.config.default_field.clone())
.unwrap_or_else(|| item.item_type.default_field().to_string());
let mut item_json = self.get_item_as_template(&item.id)?;
match item.item_type {
BitwardenItemType::Login => self.update_login_item_json(&mut item_json, &field, value),
BitwardenItemType::SecureNote => {
self.update_secure_note_item_json(&mut item_json, &field, value)
}
BitwardenItemType::Card => self.update_card_item_json(&mut item_json, &field, value),
BitwardenItemType::Identity => {
self.update_identity_item_json(&mut item_json, &field, value)
}
BitwardenItemType::SshKey => {
self.update_ssh_key_item_json(&mut item_json, &field, value)
}
}?;
self.update_item_with_json(&item.id, &item_json)
}
fn update_login_item_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
match builtin_member(BitwardenItemType::Login, field) {
Some(member) => {
item_json["login"][member] = serde_json::Value::String(value.to_string());
Ok(())
}
None => self.update_custom_field_in_json(item_json, field, value),
}
}
fn update_secure_note_item_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
if is_note_body_field(field) {
item_json["notes"] = serde_json::Value::String(value.to_string());
Ok(())
} else {
self.update_custom_field_in_json(item_json, field, value)
}
}
fn update_card_item_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
match builtin_member(BitwardenItemType::Card, field) {
Some(member) => {
item_json["card"][member] = serde_json::Value::String(value.to_string());
Ok(())
}
None => self.update_custom_field_in_json(item_json, field, value),
}
}
fn update_identity_item_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
match builtin_member(BitwardenItemType::Identity, field) {
Some(member) => {
item_json["identity"][member] = serde_json::Value::String(value.to_string());
Ok(())
}
None => self.update_custom_field_in_json(item_json, field, value),
}
}
fn update_ssh_key_item_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
match builtin_member(BitwardenItemType::SshKey, field) {
Some(member) => {
item_json["sshKey"][member] = serde_json::Value::String(value.to_string());
Ok(())
}
None => self.update_custom_field_in_json(item_json, field, value),
}
}
fn get_item_as_template(&self, item_id: &str) -> Result<serde_json::Value> {
let mut args = vec!["get", "item", item_id];
let org_id = self.resolved_org_id()?.map(str::to_string);
if let Some(org_id) = &org_id {
args.extend_from_slice(&["--organizationid", org_id]);
}
let output = self.execute_bw_command(&args)?;
let item_json: serde_json::Value = serde_json::from_str(&output)?;
Ok(item_json)
}
fn update_custom_field_in_json(
&self,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) -> Result<()> {
if item_json["fields"].is_null() {
item_json["fields"] = serde_json::Value::Array(vec![]);
}
let fields = item_json["fields"].as_array_mut().ok_or_else(|| {
SecretSpecError::ProviderOperationFailed("Invalid fields array".to_string())
})?;
for field_obj in fields.iter_mut() {
if let Some(name) = field_obj["name"].as_str()
&& name.eq_ignore_ascii_case(field)
{
field_obj["value"] = serde_json::Value::String(value.to_string());
return Ok(());
}
}
let field_type = BitwardenFieldType::for_field_name(field);
let new_field = serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
});
fields.push(new_field);
Ok(())
}
fn update_item_with_json(&self, item_id: &str, item_json: &serde_json::Value) -> Result<()> {
self.ensure_server_configured()?;
let item_json_str = serde_json::to_string(item_json)?;
use base64::{Engine as _, engine::general_purpose};
use std::process::Stdio;
let encoded_json = general_purpose::STANDARD.encode(&item_json_str);
let mut cmd = self.command();
let mut args = vec!["--nointeraction", "edit", "item", item_id];
let org_id = self.resolved_org_id()?.map(str::to_string);
if let Some(org_id) = &org_id {
args.extend_from_slice(&["--organizationid", org_id]);
}
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SecretSpecError::ProviderOperationFailed(
"Bitwarden CLI (bw) is not installed.\n\nTo install it:\n - npm: npm install -g @bitwarden/cli\n - Homebrew: brew install bitwarden-cli\n - Chocolatey: choco install bitwarden-cli\n - Download: https://bitwarden.com/help/cli/".to_string(),
)
} else {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to run `bw {}`: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
}
})?;
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(encoded_json.as_bytes()).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to send the item to `bw {}` on stdin: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
})?;
}
let output = child.wait_with_output().map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to wait for `bw {}`: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
})?;
if !output.status.success() {
return Err(bw_command_failed(
&format!("bw {}", args.join(" ")),
&output,
));
}
Ok(())
}
fn create_new_item(
&self,
item_name: &str,
target_field: Option<&str>,
value: &str,
) -> Result<()> {
let item_type = self
.resolved_item_type()?
.unwrap_or(BitwardenItemType::Login);
let field = target_field
.map(|s| s.to_string())
.or_else(|| std::env::var("BITWARDEN_DEFAULT_FIELD").ok())
.or_else(|| self.config.default_field.clone())
.unwrap_or_else(|| item_type.default_field().to_string());
let placement = self.item_placement()?;
let template = match item_type {
BitwardenItemType::Login => self.login_template(item_name, value, &field, &placement),
BitwardenItemType::Card => self.card_template(item_name, value, &field, &placement),
BitwardenItemType::Identity => {
self.identity_template(item_name, value, &field, &placement)
}
BitwardenItemType::SecureNote => {
self.secure_note_template(item_name, value, &field, &placement)
}
BitwardenItemType::SshKey => {
self.ssh_key_template(item_name, value, &field, &placement)
}
};
self.create_item_from_template(&template)
}
fn login_template(
&self,
item_name: &str,
value: &str,
field: &str,
placement: &ItemPlacement,
) -> serde_json::Value {
let mut login_data = serde_json::json!({
"username": null,
"password": null,
"totp": null,
"uris": []
});
let mut fields = vec![];
match builtin_member(BitwardenItemType::Login, field) {
Some(member) => login_data[member] = serde_json::Value::String(value.to_string()),
None => {
let field_type = BitwardenFieldType::for_field_name(field);
fields.push(serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
}));
}
}
serde_json::json!({
"type": BitwardenItemType::Login.to_u8(),
"name": item_name,
"notes": format!("SecretSpec managed secret: {}", item_name),
"login": login_data,
"fields": fields,
"organizationId": placement.organization_id.clone(),
"collectionIds": placement.collection_ids.clone()
})
}
fn card_template(
&self,
item_name: &str,
value: &str,
field: &str,
placement: &ItemPlacement,
) -> serde_json::Value {
let mut card_data = serde_json::json!({
"number": null,
"code": null,
"cardholderName": null,
"brand": null,
"expMonth": null,
"expYear": null
});
let mut fields = vec![];
match builtin_member(BitwardenItemType::Card, field) {
Some(member) => card_data[member] = serde_json::Value::String(value.to_string()),
None => {
let field_type = BitwardenFieldType::for_field_name(field);
fields.push(serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
}));
}
}
serde_json::json!({
"type": BitwardenItemType::Card.to_u8(),
"name": item_name,
"notes": format!("SecretSpec managed secret: {}", item_name),
"card": card_data,
"fields": fields,
"organizationId": placement.organization_id.clone(),
"collectionIds": placement.collection_ids.clone()
})
}
fn identity_template(
&self,
item_name: &str,
value: &str,
field: &str,
placement: &ItemPlacement,
) -> serde_json::Value {
let mut identity_data = serde_json::json!({
"title": null,
"firstName": null,
"middleName": null,
"lastName": null,
"username": null,
"company": null,
"email": null,
"phone": null
});
let mut fields = vec![];
match builtin_member(BitwardenItemType::Identity, field) {
Some(member) => identity_data[member] = serde_json::Value::String(value.to_string()),
None => {
let field_type = BitwardenFieldType::for_field_name(field);
fields.push(serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
}));
}
}
serde_json::json!({
"type": BitwardenItemType::Identity.to_u8(),
"name": item_name,
"notes": format!("SecretSpec managed secret: {}", item_name),
"identity": identity_data,
"fields": fields,
"organizationId": placement.organization_id.clone(),
"collectionIds": placement.collection_ids.clone()
})
}
fn secure_note_template(
&self,
item_name: &str,
value: &str,
field: &str,
placement: &ItemPlacement,
) -> serde_json::Value {
let mut fields = vec![];
let into_body = is_note_body_field(field);
if !into_body {
let field_type = BitwardenFieldType::for_field_name(field);
fields.push(serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
}));
}
serde_json::json!({
"type": BitwardenItemType::SecureNote.to_u8(),
"name": item_name,
"notes": if into_body { value.to_string() } else { format!("SecretSpec managed secret: {}", item_name) },
"secureNote": {
"type": 0
},
"fields": fields,
"organizationId": placement.organization_id.clone(),
"collectionIds": placement.collection_ids.clone()
})
}
fn ssh_key_template(
&self,
item_name: &str,
value: &str,
field: &str,
placement: &ItemPlacement,
) -> serde_json::Value {
let mut ssh_key_data = serde_json::json!({
"privateKey": SSH_KEY_FIELD_UNSET,
"publicKey": SSH_KEY_FIELD_UNSET,
"keyFingerprint": SSH_KEY_FIELD_UNSET
});
let mut fields = vec![];
match builtin_member(BitwardenItemType::SshKey, field) {
Some(member) => ssh_key_data[member] = serde_json::Value::String(value.to_string()),
None => {
let field_type = BitwardenFieldType::for_field_name(field);
fields.push(serde_json::json!({
"name": field,
"value": value,
"type": field_type.to_u8()
}));
}
}
serde_json::json!({
"type": BitwardenItemType::SshKey.to_u8(),
"name": item_name,
"notes": format!("SecretSpec managed secret: {}", item_name),
"sshKey": ssh_key_data,
"fields": fields,
"organizationId": placement.organization_id.clone(),
"collectionIds": placement.collection_ids.clone()
})
}
fn create_item_from_template(&self, template: &serde_json::Value) -> Result<()> {
self.ensure_server_configured()?;
let template_json = serde_json::to_string(template)?;
use base64::{Engine as _, engine::general_purpose};
use std::process::Stdio;
let encoded_json = general_purpose::STANDARD.encode(&template_json);
let mut cmd = self.command();
let mut args = vec!["--nointeraction", "create", "item"];
let org_id = self.resolved_org_id()?.map(str::to_string);
if let Some(org_id) = &org_id {
args.extend_from_slice(&["--organizationid", org_id]);
}
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
SecretSpecError::ProviderOperationFailed(
"Bitwarden CLI (bw) is not installed.\n\nTo install it:\n - npm: npm install -g @bitwarden/cli\n - Homebrew: brew install bitwarden-cli\n - Chocolatey: choco install bitwarden-cli\n - Download: https://bitwarden.com/help/cli/".to_string(),
)
} else {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to run `bw {}`: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
}
})?;
use std::io::Write;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(encoded_json.as_bytes()).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to send the item to `bw {}` on stdin: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
})?;
}
let output = child.wait_with_output().map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to wait for `bw {}`: {}",
args.join(" "),
crate::error::display_error_chain(&e)
))
})?;
if !output.status.success() {
return Err(bw_command_failed(
&format!("bw {}", args.join(" ")),
&output,
));
}
Ok(())
}
}
impl Provider for BitwardenProvider {
fn convention_address(
&self,
_project: &str,
_profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: key.to_string(),
..Default::default()
})
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field"]
}
fn with_credentials(&mut self, credentials: ProviderCredentials) {
self.credentials = credentials;
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let mut uri = String::from("bw://");
let mut params: Vec<String> = Vec::new();
match (&self.config.organization_id, &self.config.collection_id) {
(org, Some(collection)) => {
if let Some(org) = org {
uri.push_str(&ProviderUrl::encode(org));
uri.push('@');
}
uri.push_str(&ProviderUrl::encode(collection));
}
(Some(org), None) => {
params.push(format!("org={}", ProviderUrl::encode_query(org)));
}
(None, None) => {}
}
if let Some(folder) = &self.config.folder_prefix {
params.push(format!("folder={}", ProviderUrl::encode_query(folder)));
}
if let Some(item_type) = self.config.default_item_type {
params.push(format!("type={}", item_type.as_str()));
}
if let Some(field) = &self.config.default_field {
params.push(format!("field={}", ProviderUrl::encode_query(field)));
}
if let Some(server) = &self.config.server {
params.push(format!("server={}", ProviderUrl::encode_query(server)));
}
if !params.is_empty() {
uri.push('?');
uri.push_str(¶ms.join("&"));
}
uri
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.resolve_coords(addr)?;
let item_name = &coords.item;
let target_field = coords.field.as_deref();
self.get_from_password_manager(item_name, target_field)
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let coords = self.resolve_coords(addr)?;
let item_name = &coords.item;
let target_field = coords.field.as_deref();
self.set_to_password_manager(item_name, target_field, value)
}
fn reflect(&self, _context: DiscoveryContext<'_>) -> Result<HashMap<String, Secret>> {
if !self.is_authenticated()? {
return Err(SecretSpecError::ProviderOperationFailed(
"Bitwarden authentication required. Please run 'bw login' and 'bw unlock', then set the BW_SESSION environment variable.".to_string(),
));
}
let items = self.list_items(None)?;
declarations_from_items(&items, self.resolved_item_type()?)
}
}
impl Default for BitwardenProvider {
fn default() -> Self {
Self::new(BitwardenConfig::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_deserialize_linked_field_type() {
let json = r#"{
"object": "item",
"id": "test-id",
"name": "Test Item",
"type": 1,
"fields": [
{
"name": "API Key",
"value": "secret-123",
"type": 0
},
{
"name": "Related Item",
"value": null,
"type": 3,
"linkedId": "other-item-id"
}
]
}"#;
let item: BitwardenItem = serde_json::from_str(json).unwrap();
assert_eq!(item.name, "Test Item");
let fields = item.fields.unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name.as_deref(), Some("API Key"));
assert_eq!(fields[1].name.as_deref(), Some("Related Item"));
assert!(matches!(fields[1].field_type, BitwardenFieldType::Linked));
assert_eq!(
fields[1].linked_id.as_ref().and_then(|v| v.as_str()),
Some("other-item-id")
);
}
#[test]
fn test_deserialize_mixed_fields_including_linked() {
let json = r#"{
"object": "item",
"id": "mixed-id",
"name": "Mixed Fields",
"type": 1,
"fields": [
{ "name": "username", "value": "alice", "type": 0 },
{ "name": "password", "value": "s3cret", "type": 1 },
{ "name": "active", "value": "true", "type": 2 },
{ "name": "link", "value": null, "type": 3, "linkedId": "abc-123" }
]
}"#;
let item: BitwardenItem = serde_json::from_str(json).unwrap();
let fields = item.fields.unwrap();
assert_eq!(fields.len(), 4);
assert!(matches!(fields[0].field_type, BitwardenFieldType::Text));
assert!(matches!(fields[1].field_type, BitwardenFieldType::Hidden));
assert!(matches!(fields[2].field_type, BitwardenFieldType::Boolean));
assert!(matches!(fields[3].field_type, BitwardenFieldType::Linked));
}
#[test]
fn test_deserialize_linked_field_integer_id() {
let json = r#"{
"object": "item",
"id": "test-id",
"name": "Test Item",
"type": 1,
"fields": [
{
"name": "linked_field",
"value": null,
"type": 3,
"linkedId": 100
}
]
}"#;
let item: BitwardenItem = serde_json::from_str(json).unwrap();
let fields = item.fields.unwrap();
assert_eq!(fields.len(), 1);
assert!(matches!(fields[0].field_type, BitwardenFieldType::Linked));
assert_eq!(
fields[0].linked_id.as_ref().and_then(|v| v.as_u64()),
Some(100)
);
}
const REAL_STATUS_CLOUD: &str = r#"{"serverUrl":null,"lastSync":"2026-07-17T21:52:42.940Z","userEmail":"user@example.com","userId":"183fb6e7-a07f-400c-ad76-b27000074032","status":"locked"}"#;
#[test]
fn status_reports_the_public_cloud_as_null() {
assert_eq!(parse_status_server(REAL_STATUS_CLOUD).unwrap(), None);
}
#[test]
fn null_server_url_matches_the_cloud_address() {
let reported = parse_status_server(REAL_STATUS_CLOUD).unwrap();
let current = reported.as_deref().unwrap_or(BITWARDEN_CLOUD_SERVER);
assert!(servers_match("https://vault.bitwarden.com", current));
assert!(!servers_match("https://vault.company.com", current));
}
#[test]
fn status_reports_a_self_hosted_server() {
let json = r#"{"serverUrl":"https://vault.company.com","status":"unlocked"}"#;
assert_eq!(
parse_status_server(json).unwrap().as_deref(),
Some("https://vault.company.com")
);
}
#[test]
fn status_treats_missing_and_empty_server_url_as_the_cloud() {
let missing = r#"{"status":"unlocked"}"#;
let empty = r#"{"serverUrl":" ","status":"unlocked"}"#;
assert_eq!(parse_status_server(missing).unwrap(), None);
assert_eq!(parse_status_server(empty).unwrap(), None);
}
#[test]
fn status_rejects_unparseable_output() {
assert!(parse_status_server("Server URL: https://vault.company.com").is_err());
assert!(parse_status_server("").is_err());
assert!(parse_status_server(r#"{"serverUrl":42}"#).is_err());
}
#[test]
fn server_comparison_ignores_only_insignificant_differences() {
assert!(servers_match(
"https://vault.company.com",
"https://vault.company.com/"
));
assert!(servers_match(
"https://vault.company.com",
" https://vault.company.com "
));
assert!(servers_match(
"HTTPS://Vault.Company.COM",
"https://vault.company.com"
));
assert!(servers_match(
"https://vault.company.com:443",
"https://vault.company.com"
));
assert!(servers_match(
"https://vault.company.com/bitwarden",
"https://vault.company.com/bitwarden/"
));
}
#[test]
fn server_comparison_distinguishes_different_servers() {
assert!(!servers_match(
"https://vault.company.com",
"https://vault.other.com"
));
assert!(!servers_match(
"https://vault.company.com:8443",
"https://vault.company.com"
));
assert!(!servers_match(
"http://vault.company.com",
"https://vault.company.com"
));
assert!(!servers_match(
"https://vault.company.com/bitwarden",
"https://vault.company.com"
));
}
#[test]
fn server_guard_is_skipped_without_a_configured_server() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(provider.config.server.is_none());
assert!(provider.ensure_server_configured().is_ok());
}
const ALL_ITEM_TYPES: [BitwardenItemType; 5] = [
BitwardenItemType::Login,
BitwardenItemType::SecureNote,
BitwardenItemType::Card,
BitwardenItemType::Identity,
BitwardenItemType::SshKey,
];
fn item_from_template(mut template: serde_json::Value) -> BitwardenItem {
template["id"] = serde_json::Value::String("test-id".to_string());
serde_json::from_value(template)
.expect("a creation template must deserialize as a vault item")
}
fn template_for(
provider: &BitwardenProvider,
item_type: BitwardenItemType,
name: &str,
value: &str,
field: &str,
) -> serde_json::Value {
let placement = ItemPlacement::default();
match item_type {
BitwardenItemType::Login => provider.login_template(name, value, field, &placement),
BitwardenItemType::SecureNote => {
provider.secure_note_template(name, value, field, &placement)
}
BitwardenItemType::Card => provider.card_template(name, value, field, &placement),
BitwardenItemType::Identity => {
provider.identity_template(name, value, field, &placement)
}
BitwardenItemType::SshKey => provider.ssh_key_template(name, value, field, &placement),
}
}
fn read_without_naming_a_field(
provider: &BitwardenProvider,
item: &BitwardenItem,
) -> Option<String> {
let extracted = match item.item_type {
BitwardenItemType::Login => provider.extract_from_login_item(item, None),
BitwardenItemType::SecureNote => provider.extract_from_secure_note_item(item, None),
BitwardenItemType::Card => provider.extract_from_card_item(item, None),
BitwardenItemType::Identity => provider.extract_from_identity_item(item, None),
BitwardenItemType::SshKey => provider.extract_from_ssh_key_item(item, None),
};
extracted
.expect("extraction must not fail")
.map(|secret| secret.expose_secret().to_string())
}
fn read_naming_a_field(
provider: &BitwardenProvider,
item: &BitwardenItem,
field: &str,
) -> Option<String> {
provider
.extract_value_from_item(item, Some(field))
.expect("extraction must not fail")
.map(|secret| secret.expose_secret().to_string())
}
const BUILTIN_FIELD_ALIASES: &[(BitwardenItemType, &str)] = &[
(BitwardenItemType::Card, "exp_month"),
(BitwardenItemType::Card, "expmonth"),
(BitwardenItemType::Card, "exp_year"),
(BitwardenItemType::Card, "expyear"),
(BitwardenItemType::Card, "number"),
(BitwardenItemType::Card, "brand"),
(BitwardenItemType::Card, "code"),
(BitwardenItemType::Identity, "first_name"),
(BitwardenItemType::Identity, "firstname"),
(BitwardenItemType::Identity, "last_name"),
(BitwardenItemType::Identity, "lastname"),
(BitwardenItemType::Identity, "email"),
(BitwardenItemType::Identity, "company"),
(BitwardenItemType::Login, "username"),
(BitwardenItemType::Login, "totp"),
(BitwardenItemType::SshKey, "public_key"),
(BitwardenItemType::SshKey, "fingerprint"),
(BitwardenItemType::SecureNote, "notes"),
];
#[test]
fn a_built_in_field_named_on_create_is_readable_under_that_name() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for (item_type, field) in BUILTIN_FIELD_ALIASES {
let template = template_for(&provider, *item_type, "Built In", "written-value", field);
let item = item_from_template(template);
assert_eq!(
read_naming_a_field(&provider, &item, field).as_deref(),
Some("written-value"),
"{item_type:?}: value written to field={field} was not readable under that name",
);
}
}
#[test]
fn a_built_in_field_survives_an_update_to_the_item_it_created() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for (item_type, field) in BUILTIN_FIELD_ALIASES {
let mut item_json =
template_for(&provider, *item_type, "Built In", "created-value", field);
apply_update(
&provider,
*item_type,
&mut item_json,
field,
"updated-value",
);
let item = item_from_template(item_json);
assert_eq!(
read_naming_a_field(&provider, &item, field).as_deref(),
Some("updated-value"),
"{item_type:?}: update to field={field} was not readable under that name",
);
}
}
#[test]
fn ssh_key_template_never_emits_a_null_member() {
let provider = BitwardenProvider::default();
for field in [
"private_key",
"privatekey",
"private",
"public_key",
"publickey",
"public",
"fingerprint",
"key_fingerprint",
"something_custom",
] {
let template = template_for(
&provider,
BitwardenItemType::SshKey,
"item",
"the-secret",
field,
);
let ssh_key = &template["sshKey"];
for member in ["privateKey", "publicKey", "keyFingerprint"] {
let value = &ssh_key[member];
assert!(
value.is_string(),
"sshKey.{member} must be a string when writing field '{field}', got {value}"
);
assert!(
!value.as_str().unwrap().is_empty(),
"sshKey.{member} must be non-empty when writing field '{field}'"
);
}
}
}
#[test]
fn ssh_key_template_puts_the_secret_in_the_addressed_member() {
let provider = BitwardenProvider::default();
for (field, member) in [
("private_key", "privateKey"),
("public_key", "publicKey"),
("key_fingerprint", "keyFingerprint"),
] {
let template = template_for(
&provider,
BitwardenItemType::SshKey,
"item",
"the-secret",
field,
);
assert_eq!(
template["sshKey"][member], "the-secret",
"field '{field}' must land in sshKey.{member}"
);
}
}
#[test]
fn default_field_table_is_pinned() {
assert_eq!(BitwardenItemType::Login.default_field(), "password");
assert_eq!(BitwardenItemType::SecureNote.default_field(), "value");
assert_eq!(BitwardenItemType::Card.default_field(), "number");
assert_eq!(BitwardenItemType::Identity.default_field(), "email");
assert_eq!(BitwardenItemType::SshKey.default_field(), "private_key");
}
#[test]
fn plain_set_round_trips_for_every_item_type() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for item_type in ALL_ITEM_TYPES {
let template = template_for(
&provider,
item_type,
"Round Trip",
"secret-value",
item_type.default_field(),
);
let item = item_from_template(template);
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("secret-value"),
"{item_type:?}: value written to the default field was not readable without naming a field"
);
}
}
#[test]
fn named_custom_field_round_trips_for_every_item_type() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for item_type in ALL_ITEM_TYPES {
let template = template_for(
&provider,
item_type,
"Named Field",
"sk_test_123",
"api_key",
);
let item = item_from_template(template);
let by_name = provider
.extract_from_custom_fields(&item, "api_key")
.expect("extraction must not fail");
assert_eq!(
by_name.as_deref(),
Some("sk_test_123"),
"{item_type:?}: value written to field=api_key was not stored as that custom field"
);
}
}
fn apply_update(
provider: &BitwardenProvider,
item_type: BitwardenItemType,
item_json: &mut serde_json::Value,
field: &str,
value: &str,
) {
let result = match item_type {
BitwardenItemType::Login => provider.update_login_item_json(item_json, field, value),
BitwardenItemType::SecureNote => {
provider.update_secure_note_item_json(item_json, field, value)
}
BitwardenItemType::Card => provider.update_card_item_json(item_json, field, value),
BitwardenItemType::Identity => {
provider.update_identity_item_json(item_json, field, value)
}
BitwardenItemType::SshKey => provider.update_ssh_key_item_json(item_json, field, value),
};
result.expect("update must not fail");
}
#[test]
fn update_after_create_round_trips_for_every_item_type() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for item_type in ALL_ITEM_TYPES {
let mut item_json = template_for(
&provider,
item_type,
"Update Round Trip",
"first-value",
item_type.default_field(),
);
apply_update(
&provider,
item_type,
&mut item_json,
item_type.default_field(),
"second-value",
);
let item = item_from_template(item_json);
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("second-value"),
"{item_type:?}: update wrote somewhere an unqualified read does not look"
);
}
}
#[test]
fn update_reaches_the_field_reads_prefer_on_a_legacy_secure_note() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let mut item_json = serde_json::json!({
"type": BitwardenItemType::SecureNote.to_u8(),
"name": "Legacy Note",
"notes": "stale-body",
"secureNote": { "type": 0 },
"fields": [
{ "name": "value", "value": "stale-custom-field", "type": 1 }
]
});
apply_update(
&provider,
BitwardenItemType::SecureNote,
&mut item_json,
BitwardenItemType::SecureNote.default_field(),
"fresh-value",
);
let item = item_from_template(item_json);
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("fresh-value"),
"update must write the field an unqualified read consults first"
);
}
#[test]
fn default_field_does_not_depend_on_the_item_name() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for name in [
"MY_TOTP_SECRET",
"cardholder name",
"user login",
"public key",
] {
for item_type in ALL_ITEM_TYPES {
let template =
template_for(&provider, item_type, name, "v", item_type.default_field());
let item = item_from_template(template);
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("v"),
"{item_type:?} named {name:?}: the name must not change where the value lands"
);
}
}
}
#[test]
fn test_collection_id_from_uri() {
let url = url::Url::parse("bw://my-collection").unwrap();
let purl = ProviderUrl::new(url);
let config = BitwardenConfig::try_from(&purl).unwrap();
assert_eq!(config.collection_id.as_deref(), Some("my-collection"));
assert!(config.organization_id.is_none());
}
#[test]
fn test_org_collection_from_uri() {
let url = url::Url::parse("bw://myorg@dev-secrets").unwrap();
let purl = ProviderUrl::new(url);
let config = BitwardenConfig::try_from(&purl).unwrap();
assert_eq!(config.organization_id.as_deref(), Some("myorg"));
assert_eq!(config.collection_id.as_deref(), Some("dev-secrets"));
}
#[test]
fn test_collection_from_query_param() {
let url = url::Url::parse("bw://?collection=prod-secrets").unwrap();
let purl = ProviderUrl::new(url);
let config = BitwardenConfig::try_from(&purl).unwrap();
assert_eq!(config.collection_id.as_deref(), Some("prod-secrets"));
}
#[test]
fn test_update_custom_field_case_insensitive() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let mut item_json = serde_json::json!({
"fields": [
{ "name": "API_KEY", "value": "old-value", "type": 0 }
]
});
provider
.update_custom_field_in_json(&mut item_json, "api_key", "new-value")
.unwrap();
let fields = item_json["fields"].as_array().unwrap();
assert_eq!(
fields.len(),
1,
"should update existing field, not add duplicate"
);
assert_eq!(fields[0]["name"].as_str(), Some("API_KEY"));
assert_eq!(fields[0]["value"].as_str(), Some("new-value"));
}
const ACME_ID: &str = "11111111-1111-4111-8111-111111111111";
const GLOBEX_ID: &str = "22222222-2222-4222-8222-222222222222";
const ACME_DEV_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const ACME_PROD_ID: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const GLOBEX_DEV_ID: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const UMLAUT_ID: &str = "33333333-3333-4333-8333-333333333333";
const ORGANIZATIONS_JSON: &str = r#"[
{"object":"organization","id":"11111111-1111-4111-8111-111111111111","name":"Acme Inc","status":2,"type":0,"enabled":true},
{"object":"organization","id":"22222222-2222-4222-8222-222222222222","name":"Globex","status":2,"type":0,"enabled":true},
{"object":"organization","id":"33333333-3333-4333-8333-333333333333","name":"ÜBERBLICK","status":2,"type":0,"enabled":true}
]"#;
const COLLECTIONS_JSON: &str = r#"[
{"object":"collection","id":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","organizationId":"11111111-1111-4111-8111-111111111111","name":"dev-secrets","externalId":null},
{"object":"collection","id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","organizationId":"11111111-1111-4111-8111-111111111111","name":"prod-secrets","externalId":null},
{"object":"collection","id":"cccccccc-cccc-4ccc-8ccc-cccccccccccc","organizationId":"22222222-2222-4222-8222-222222222222","name":"dev-secrets","externalId":null},
{"object":"collection","id":"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee","organizationId":"33333333-3333-4333-8333-333333333333","name":"Geheimnisse","externalId":null}
]"#;
fn resolve(
org: Option<&str>,
collection: Option<&str>,
) -> std::result::Result<VaultScope, String> {
resolve_scope(ORGANIZATIONS_JSON, COLLECTIONS_JSON, org, collection)
}
#[test]
fn an_unscoped_address_resolves_to_nothing() {
assert_eq!(resolve(None, None).unwrap(), VaultScope::default());
assert_eq!(
resolve_scope("not json", "not json", None, None).unwrap(),
VaultScope::default(),
"an unscoped address must not even parse the listings"
);
}
#[test]
fn empty_cli_output_is_an_empty_list_not_a_parse_error() {
let err = resolve_scope("", "", None, Some("dev-secrets")).unwrap_err();
assert!(
!err.contains("could not parse"),
"empty output must not read as malformed output: {err}"
);
assert!(
err.contains("No collection matching 'dev-secrets'"),
"{err}"
);
assert!(err.contains("BW_SESSION"), "{err}");
assert!(err.contains("bw sync"), "{err}");
assert!(
resolve_scope("\n", " \n", None, Some("x"))
.unwrap_err()
.contains("No collection matching"),
);
}
#[test]
fn genuinely_malformed_output_is_still_reported_as_such() {
let err = resolve_scope("not json", COLLECTIONS_JSON, Some("Acme Inc"), None).unwrap_err();
assert!(
err.contains("could not parse `bw list organizations`"),
"{err}"
);
}
#[test]
fn names_resolve_to_ids() {
let scope = resolve(Some("Acme Inc"), Some("dev-secrets")).unwrap();
assert_eq!(scope.organization_id.as_deref(), Some(ACME_ID));
assert_eq!(scope.collection_id.as_deref(), Some(ACME_DEV_ID));
}
#[test]
fn names_match_case_insensitively() {
let scope = resolve(Some("acme inc"), Some("DEV-SECRETS")).unwrap();
assert_eq!(scope.organization_id.as_deref(), Some(ACME_ID));
assert_eq!(scope.collection_id.as_deref(), Some(ACME_DEV_ID));
}
#[test]
fn names_fold_case_beyond_ascii() {
let scope = resolve(Some("überblick"), None).unwrap();
assert_eq!(scope.organization_id.as_deref(), Some(UMLAUT_ID));
}
#[test]
fn ids_still_resolve_and_are_validated() {
let scope = resolve(Some(ACME_ID), Some(ACME_PROD_ID)).unwrap();
assert_eq!(scope.organization_id.as_deref(), Some(ACME_ID));
assert_eq!(scope.collection_id.as_deref(), Some(ACME_PROD_ID));
let err = resolve(None, Some("dddddddd-dddd-4ddd-8ddd-dddddddddddd")).unwrap_err();
assert!(err.contains("No collection matching"), "{err}");
assert!(err.contains("bw sync"), "{err}");
}
#[test]
fn a_collection_addressed_alone_supplies_its_organization() {
let scope = resolve(None, Some("prod-secrets")).unwrap();
assert_eq!(scope.collection_id.as_deref(), Some(ACME_PROD_ID));
assert_eq!(
scope.organization_id.as_deref(),
Some(ACME_ID),
"the organization must be derived from the collection"
);
}
#[test]
fn an_organization_addressed_alone_resolves_without_a_collection() {
let scope = resolve(Some("Globex"), None).unwrap();
assert_eq!(scope.organization_id.as_deref(), Some(GLOBEX_ID));
assert_eq!(scope.collection_id, None);
}
#[test]
fn an_organization_disambiguates_a_duplicated_collection_name() {
let acme = resolve(Some("Acme Inc"), Some("dev-secrets")).unwrap();
let globex = resolve(Some("Globex"), Some("dev-secrets")).unwrap();
assert_eq!(acme.collection_id.as_deref(), Some(ACME_DEV_ID));
assert_eq!(globex.collection_id.as_deref(), Some(GLOBEX_DEV_ID));
assert_ne!(
acme.collection_id, globex.collection_id,
"the same collection name in two organizations must resolve apart"
);
}
#[test]
fn an_ambiguous_collection_name_is_rejected() {
let err = resolve(None, Some("dev-secrets")).unwrap_err();
assert!(err.contains("ambiguous"), "{err}");
assert!(err.contains("Acme Inc"), "{err}");
assert!(err.contains("Globex"), "{err}");
}
#[test]
fn a_collection_in_another_organization_is_rejected_by_name() {
let err = resolve(Some("Globex"), Some("prod-secrets")).unwrap_err();
assert!(err.contains("not in organization"), "{err}");
assert!(err.contains("Globex"), "{err}");
assert!(err.contains("Acme Inc"), "{err}");
}
#[test]
fn a_collection_id_from_another_organization_is_rejected() {
let err = resolve(Some("Globex"), Some(ACME_DEV_ID)).unwrap_err();
assert!(err.contains("belongs to organization"), "{err}");
assert!(err.contains("Acme Inc"), "{err}");
assert!(err.contains("Globex"), "{err}");
}
#[test]
fn an_unknown_organization_lists_the_ones_that_exist() {
let err = resolve(Some("Initech"), None).unwrap_err();
assert!(err.contains("No organization matching 'Initech'"), "{err}");
assert!(err.contains("Acme Inc"), "{err}");
assert!(err.contains("Globex"), "{err}");
}
#[test]
fn an_unknown_collection_lists_only_the_addressed_organization() {
let err = resolve(Some("Acme Inc"), Some("staging")).unwrap_err();
assert!(err.contains("No collection matching 'staging'"), "{err}");
assert!(err.contains("prod-secrets"), "{err}");
assert!(
!err.contains(GLOBEX_DEV_ID),
"collections outside the addressed organization must not be offered: {err}"
);
}
#[test]
fn search_sends_at_most_one_filter() {
let collection_scope = VaultScope {
organization_id: Some(ACME_ID.to_string()),
collection_id: Some(ACME_DEV_ID.to_string()),
};
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.vault_scope
.set(Ok(collection_scope))
.expect("scope is set once");
let args = provider.search_filter_args().unwrap();
assert_eq!(
args,
vec!["--collectionid".to_string(), ACME_DEV_ID.to_string()]
);
assert!(
!args.iter().any(|a| a == "--organizationid"),
"a resolved collection already implies its organization: {args:?}"
);
}
#[test]
fn search_falls_back_to_the_organization_filter() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.vault_scope
.set(Ok(VaultScope {
organization_id: Some(GLOBEX_ID.to_string()),
collection_id: None,
}))
.expect("scope is set once");
assert_eq!(
provider.search_filter_args().unwrap(),
vec!["--organizationid".to_string(), GLOBEX_ID.to_string()]
);
}
#[test]
fn an_unscoped_search_sends_no_filter() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.vault_scope
.set(Ok(VaultScope::default()))
.expect("scope is set once");
assert!(provider.search_filter_args().unwrap().is_empty());
}
#[test]
fn creation_places_the_item_in_the_resolved_collection() {
let placement = ItemPlacement::from(&VaultScope {
organization_id: Some(ACME_ID.to_string()),
collection_id: Some(ACME_DEV_ID.to_string()),
});
let provider = BitwardenProvider::new(BitwardenConfig::default());
let template = provider.login_template("Shared Secret", "v", "password", &placement);
assert_eq!(template["organizationId"].as_str(), Some(ACME_ID));
assert_eq!(
template["collectionIds"].as_array().map(Vec::as_slice),
Some([serde_json::Value::String(ACME_DEV_ID.to_string())].as_slice())
);
}
#[test]
fn an_unscoped_creation_names_no_organization() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let template = provider.login_template("x", "v", "password", &ItemPlacement::default());
assert!(template["organizationId"].is_null());
assert!(template["collectionIds"].is_null());
}
#[cfg(unix)]
fn finished(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
use std::os::unix::process::ExitStatusExt;
std::process::Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
}
}
#[test]
#[cfg(unix)]
fn a_failed_command_names_itself_and_its_status() {
let err = bw_command_failed("bw list items", &finished(1, "", "Vault is locked."));
let msg = err.to_string();
assert!(msg.contains("bw list items"), "{msg}");
assert!(msg.contains("exit status 1"), "{msg}");
assert!(msg.contains("Vault is locked."), "{msg}");
}
#[test]
#[cfg(unix)]
fn a_failed_command_falls_back_to_stdout() {
let err = bw_command_failed("bw create item", &finished(1, "Cipher already exists", ""));
assert!(err.to_string().contains("Cipher already exists"), "{err}");
}
#[test]
#[cfg(unix)]
fn a_failed_command_still_reports_when_both_streams_are_empty() {
let err = bw_command_failed("bw sync", &finished(2, "", ""));
let msg = err.to_string();
assert!(msg.contains("bw sync"), "{msg}");
assert!(msg.contains("exit status 2"), "{msg}");
assert!(!msg.trim_end().ends_with(':'), "{msg}");
}
#[test]
fn a_reported_cause_keeps_the_chain_underneath_it() {
let source = std::io::Error::other("broken pipe while writing");
let rendered = crate::error::display_error_chain(&source);
assert!(rendered.contains("broken pipe while writing"), "{rendered}");
}
fn config_error(spec: &str) -> String {
let url = url::Url::parse(spec).expect("the spec must parse");
BitwardenConfig::try_from(&ProviderUrl::new(url))
.expect_err("the spec must be rejected")
.to_string()
}
#[test]
fn a_misspelled_item_type_is_rejected_rather_than_ignored() {
let msg = config_error("bw://?type=sshkee");
assert!(msg.contains("sshkee"), "{msg}");
assert!(msg.contains("ssh"), "{msg}");
}
#[test]
fn a_misspelled_query_key_is_rejected_rather_than_ignored() {
let msg = config_error("bw://?feild=api_key");
assert!(msg.contains("feild"), "{msg}");
assert!(msg.contains("field"), "{msg}");
}
#[test]
fn every_documented_query_key_is_still_accepted() {
for spec in [
"bw://?org=acme",
"bw://?organization=acme",
"bw://?collection=dev",
"bw://?server=https://vault.example.com",
"bw://?folder=team",
"bw://?type=note",
"bw://?field=api_key",
] {
let url = url::Url::parse(spec).expect("the spec must parse");
assert!(
BitwardenConfig::try_from(&ProviderUrl::new(url)).is_ok(),
"{spec} was rejected",
);
}
}
#[test]
fn an_item_type_is_parsed_the_same_way_wherever_it_is_named() {
assert_eq!(
parse_item_type("note", "?type=").expect("a valid spelling"),
BitwardenItemType::SecureNote,
);
let msg = parse_item_type("garbage", "BITWARDEN_DEFAULT_TYPE")
.expect_err("an invalid spelling")
.to_string();
assert!(msg.contains("BITWARDEN_DEFAULT_TYPE"), "{msg}");
}
fn config_from_spec(spec: &str) -> BitwardenConfig {
let url = url::Url::parse(spec).expect("the spec must parse");
BitwardenConfig::try_from(&ProviderUrl::new(url)).expect("the spec must be valid")
}
fn uri_of(spec: &str) -> String {
BitwardenProvider::new(config_from_spec(spec)).uri()
}
fn store_identity(spec: &str) -> String {
format!("{:?}", config_from_spec(spec))
}
#[test]
fn uri_round_trips_every_behaviour_changing_option() {
for spec in [
"bw://",
"bw://my-collection",
"bw://myorg@dev-secrets",
"bw://?type=card",
"bw://?field=api_key",
"bw://?folder=team/{project}",
"bw://?server=https://vault.company.com",
"bw://myorg@dev-secrets?type=card&field=api_key",
] {
let rendered = uri_of(spec);
assert_eq!(
store_identity(&rendered),
store_identity(spec),
"{spec} rendered as {rendered}, which does not read back as the same store",
);
}
}
#[test]
fn uri_keeps_an_organization_addressed_without_a_collection() {
let rendered = uri_of("bw://?org=myorg");
assert_eq!(
config_from_spec(&rendered).organization_id.as_deref(),
Some("myorg"),
"rendered as {rendered}, which loses the organization",
);
}
#[test]
fn uri_escapes_values_that_would_break_the_query() {
let rendered = uri_of("bw://?folder=a%26b%3Dc");
assert_eq!(
config_from_spec(&rendered).folder_prefix.as_deref(),
Some("a&b=c"),
"rendered as {rendered}, which does not survive re-parsing",
);
}
fn note_with_value_field_and_body() -> BitwardenItem {
serde_json::from_value(serde_json::json!({
"id": "note",
"name": "Config",
"type": BitwardenItemType::SecureNote.to_u8(),
"notes": "the-note-body",
"fields": [{"name": "value", "value": "the-value-field", "type": 1}],
}))
.expect("a minimal note must deserialize")
}
#[test]
fn an_absent_secure_note_field_returns_nothing_rather_than_another_secret() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = note_with_value_field_and_body();
let got = provider
.extract_from_secure_note_item(&item, Some("config_value"))
.expect("extraction must not fail");
assert!(
got.is_none(),
"field=config_value returned '{}'",
got.map(|s| s.expose_secret().to_string())
.unwrap_or_default(),
);
}
#[test]
fn field_notes_reads_the_note_body_not_the_value_field() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = note_with_value_field_and_body();
let got = provider
.extract_from_secure_note_item(&item, Some("notes"))
.expect("extraction must not fail")
.expect("the note has a body");
assert_eq!(got.expose_secret(), "the-note-body");
}
#[test]
fn an_unqualified_secure_note_read_still_prefers_the_legacy_value_field() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = note_with_value_field_and_body();
let got = provider
.extract_from_secure_note_item(&item, None)
.expect("extraction must not fail")
.expect("the legacy field is present");
assert_eq!(got.expose_secret(), "the-value-field");
}
fn named_item(id: &str, name: &str, item_type: BitwardenItemType) -> BitwardenItem {
serde_json::from_value(serde_json::json!({
"id": id,
"name": name,
"type": item_type.to_u8(),
}))
.expect("a minimal item must deserialize")
}
#[test]
fn a_read_does_not_answer_with_a_similarly_named_item() {
let items = [
named_item("old", "API_KEY_OLD", BitwardenItemType::Login),
named_item("wanted", "API_KEY", BitwardenItemType::Login),
];
let hit = find_addressed_item(&items, "API_KEY", None)
.expect("one API_KEY item is not ambiguous")
.expect("the item exists");
assert_eq!(
hit.id, "wanted",
"a read of API_KEY answered with {} instead",
hit.name
);
}
#[test]
fn a_read_honours_an_explicitly_addressed_type() {
let items = [
named_item("login", "API_KEY", BitwardenItemType::Login),
named_item("card", "API_KEY", BitwardenItemType::Card),
];
let hit = find_addressed_item(&items, "API_KEY", Some(BitwardenItemType::Card))
.expect("the type filter leaves exactly one")
.expect("the card exists");
assert_eq!(hit.id, "card", "?type=card selected a {:?}", hit.item_type);
}
#[test]
fn a_write_never_adopts_a_substring_match() {
let items = [named_item("old", "OLD_API_KEY", BitwardenItemType::Login)];
assert!(
find_addressed_item(&items, "API_KEY", None)
.expect("no match is not an error")
.is_none(),
"a write to API_KEY selected OLD_API_KEY as its update target"
);
}
#[test]
fn an_ambiguous_name_is_refused_rather_than_guessed() {
let items = [
named_item("first", "API_KEY", BitwardenItemType::Login),
named_item("second", "API_KEY", BitwardenItemType::Login),
];
let err =
find_addressed_item(&items, "API_KEY", None).expect_err("two items share the name");
let msg = err.to_string();
assert!(msg.contains("2 Bitwarden items"), "{msg}");
assert!(msg.contains("first") && msg.contains("second"), "{msg}");
assert!(msg.contains("ref = { item ="), "{msg}");
}
#[test]
fn an_addressed_type_selects_between_same_named_items_on_write() {
let items = [named_item("login", "API_KEY", BitwardenItemType::Login)];
assert!(
find_addressed_item(&items, "API_KEY", Some(BitwardenItemType::Card))
.expect("a type mismatch is not an error")
.is_none(),
"?type=card adopted a Login as its update target",
);
}
#[test]
fn addressing_folds_case_like_the_bw_cli() {
let items = [
named_item("db", "Test Database", BitwardenItemType::Login),
named_item("u", "Überblick", BitwardenItemType::SecureNote),
];
assert_eq!(
find_addressed_item(&items, "test database", None)
.unwrap()
.map(|i| i.id.as_str()),
Some("db"),
);
assert_eq!(
find_addressed_item(&items, "überblick", None)
.unwrap()
.map(|i| i.id.as_str()),
Some("u"),
"an ASCII-only fold leaves non-ASCII names unaddressable",
);
}
#[test]
fn reflection_builds_declarations_for_the_addressed_item_type() {
let items = [
named_item("login", "API_KEY", BitwardenItemType::Login),
named_item("card", "API_KEY", BitwardenItemType::Card),
named_item("token", "CARD_TOKEN", BitwardenItemType::Card),
];
let declarations = declarations_from_items(&items, Some(BitwardenItemType::Card))
.expect("the type filter makes every item addressable");
assert_eq!(declarations.len(), 2);
assert_eq!(
declarations["API_KEY"].description.as_deref(),
Some("API_KEY Bitwarden secret")
);
assert_eq!(declarations["API_KEY"].required, Some(true));
assert!(declarations.contains_key("CARD_TOKEN"));
}
#[test]
fn reflection_rejects_names_that_cannot_be_declared() {
let items = [named_item(
"invalid",
"Database Login",
BitwardenItemType::Login,
)];
let err = declarations_from_items(&items, None)
.unwrap_err()
.to_string();
assert!(
err.contains("cannot become a SecretSpec declaration"),
"{err}"
);
assert!(err.contains("Database Login"), "{err}");
assert!(err.contains("collection and/or `?type=`"), "{err}");
}
#[test]
fn reflection_rejects_the_reserved_defaults_name() {
let items = [named_item(
"reserved",
"defaults",
BitwardenItemType::SecureNote,
)];
let err = declarations_from_items(&items, None)
.unwrap_err()
.to_string();
assert!(err.contains("reserved for profile defaults"), "{err}");
}
#[test]
fn reflection_rejects_case_insensitive_name_collisions() {
let items = [
named_item("first", "API_KEY", BitwardenItemType::Login),
named_item("second", "api_key", BitwardenItemType::Login),
];
let err = declarations_from_items(&items, None)
.unwrap_err()
.to_string();
assert!(err.contains("collide case-insensitively"), "{err}");
assert!(err.contains("first") && err.contains("second"), "{err}");
}
fn item_from(json: serde_json::Value) -> BitwardenItem {
serde_json::from_value(json).expect("fixture must deserialize as a vault item")
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_clean_env<T>(body: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let saved = [
"BITWARDEN_ORGANIZATION",
"BITWARDEN_COLLECTION",
"BITWARDEN_DEFAULT_TYPE",
]
.map(|key| (key, std::env::var(key).ok()));
for (key, _) in &saved {
unsafe { std::env::remove_var(key) };
}
let result = body();
for (key, previous) in saved {
match previous {
Some(previous) => unsafe { std::env::set_var(key, previous) },
None => unsafe { std::env::remove_var(key) },
}
}
result
}
fn with_env<T>(key: &str, value: &str, body: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let previous = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
let result = body();
match previous {
Some(previous) => unsafe { std::env::set_var(key, previous) },
None => unsafe { std::env::remove_var(key) },
}
result
}
#[cfg(unix)]
static BW_SHIM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
struct FakeBw {
dir: std::path::PathBuf,
}
#[cfg(unix)]
struct PathRestore {
old_path: String,
old_appdata: Option<String>,
}
#[cfg(unix)]
impl Drop for PathRestore {
fn drop(&mut self) {
unsafe { std::env::set_var("PATH", &self.old_path) };
match &self.old_appdata {
Some(appdata) => unsafe { std::env::set_var("BITWARDENCLI_APPDATA_DIR", appdata) },
None => unsafe { std::env::remove_var("BITWARDENCLI_APPDATA_DIR") },
}
}
}
#[cfg(unix)]
impl FakeBw {
fn new() -> FakeBw {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"bw-shim-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).expect("create fake bw directory");
let script = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../tests/fixtures/bw-shim.sh"
));
std::fs::write(dir.join("bw"), script).expect("install fake bw script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(dir.join("bw"))
.expect("stat fake bw")
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(dir.join("bw"), perms).expect("chmod fake bw");
}
FakeBw { dir }
}
fn with_status(self, status: serde_json::Value) -> Self {
std::fs::write(self.dir.join("status.json"), status.to_string())
.expect("write status fixture");
self
}
fn with_organizations(self, organizations: serde_json::Value) -> Self {
std::fs::write(
self.dir.join("organizations.json"),
organizations.to_string(),
)
.expect("write organizations fixture");
self
}
fn with_collections(self, collections: serde_json::Value) -> Self {
std::fs::write(self.dir.join("collections.json"), collections.to_string())
.expect("write collections fixture");
self
}
fn with_items(self, items: serde_json::Value) -> Self {
std::fs::write(self.dir.join("items.json"), items.to_string())
.expect("write items fixture");
self
}
fn with_failure(self, code: i32, stdout: &str, stderr: &str) -> Self {
std::fs::write(
self.dir.join("fail.env"),
format!("{code}\n{stdout}\n{stderr}"),
)
.expect("write failure fixture");
self
}
fn with_failure_on(self, code: i32, stdout: &str, stderr: &str, arg: &str) -> Self {
std::fs::write(
self.dir.join("fail.env"),
format!("{code}\n{stdout}\n{stderr}\n{arg}"),
)
.expect("write failure fixture");
self
}
fn with_garbage(self, bytes: &[u8]) -> Self {
std::fs::write(self.dir.join("garbage.bin"), bytes).expect("write garbage fixture");
self
}
fn invocations(&self) -> String {
std::fs::read_to_string(self.dir.join("invocations.log")).unwrap_or_default()
}
fn run<T>(&self, body: impl FnOnce() -> T) -> T {
let _guard = BW_SHIM_LOCK.lock().unwrap();
let old_path = std::env::var("PATH").unwrap_or_default();
let old_appdata = std::env::var("BITWARDENCLI_APPDATA_DIR").ok();
let new_path = if old_path.is_empty() {
self.dir.display().to_string()
} else {
format!("{}:{}", self.dir.display(), old_path)
};
let _restore = PathRestore {
old_path,
old_appdata,
};
unsafe {
std::env::set_var("PATH", new_path);
std::env::set_var("BITWARDENCLI_APPDATA_DIR", self.dir.join("appdata"));
}
body()
}
}
#[cfg(unix)]
impl Drop for FakeBw {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(unix)]
fn provider_without_bw() -> BitwardenProvider {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let missing = std::env::temp_dir().join(format!(
"missing-bw-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let mut provider = BitwardenProvider::new(BitwardenConfig::default());
provider.cli_binary_path = missing;
provider
}
#[cfg(unix)]
#[test]
fn check_server_accepts_a_matching_server_url() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": "https://vault.company.com",
"status": "unlocked"
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert_eq!(provider.check_server("https://vault.company.com"), Ok(()));
});
}
#[cfg(unix)]
#[test]
fn check_server_accepts_the_public_cloud_when_expected() {
let fake = FakeBw::new(); fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert_eq!(provider.check_server(BITWARDEN_CLOUD_SERVER), Ok(()));
});
}
#[cfg(unix)]
#[test]
fn check_server_rejects_a_mismatched_server_with_remediation() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": "https://vault.other.com",
"status": "unlocked"
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.check_server("https://vault.company.com")
.unwrap_err();
assert!(err.contains("expects https://vault.company.com"), "{err}");
assert!(
err.contains("bw config server https://vault.company.com"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn check_server_names_the_public_cloud_as_the_current_server() {
let fake = FakeBw::new(); fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.check_server("https://vault.company.com")
.unwrap_err();
assert!(
err.contains(&format!(
"the public Bitwarden cloud ({BITWARDEN_CLOUD_SERVER})"
)),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn check_server_ignores_a_missing_cli() {
let provider = provider_without_bw();
assert_eq!(provider.check_server("https://vault.company.com"), Ok(()));
}
#[cfg(unix)]
#[test]
fn check_server_reports_a_failed_status_call() {
let fake = FakeBw::new().with_failure(1, "", "boom");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.check_server("https://vault.company.com")
.unwrap_err();
assert!(err.contains("`bw status` failed while verifying"), "{err}");
assert!(err.contains("boom"), "{err}");
});
}
#[cfg(unix)]
#[test]
fn check_server_reports_unparseable_status_output() {
let fake = FakeBw::new().with_failure(0, "not json", "");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.check_server("https://vault.company.com")
.unwrap_err();
assert!(
err.contains("could not parse `bw status` output as JSON"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_returns_the_stdout_of_a_successful_call() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let out = provider.execute_bw_command(&["list", "items"]).unwrap();
let items: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(items[0]["name"], "Vault");
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_reports_a_missing_cli_with_install_instructions() {
let provider = provider_without_bw();
let err = provider.execute_bw_command(&["status"]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Bitwarden CLI (bw) is not installed"), "{msg}");
assert!(msg.contains("brew install bitwarden-cli"), "{msg}");
}
#[cfg(unix)]
#[test]
fn execute_bw_command_maps_a_not_logged_in_error() {
let fake = FakeBw::new().with_failure(1, "", "You are not logged in.");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.execute_bw_command(&["status"]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Please run 'bw login' first"), "{msg}");
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_maps_a_locked_vault_error() {
let fake = FakeBw::new().with_failure(1, "", "Vault is locked.");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.execute_bw_command(&["status"]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Please run 'bw unlock'"), "{msg}");
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_reports_generic_failures_with_stderr() {
let fake = FakeBw::new().with_failure(3, "", "some generic failure");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.execute_bw_command(&["list", "items"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("`bw list items` failed (exit status 3)"),
"{msg}"
);
assert!(msg.contains("some generic failure"), "{msg}");
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_falls_back_to_stdout_for_failure_detail() {
let fake = FakeBw::new().with_failure(1, "detail on stdout", "");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.execute_bw_command(&["status"]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("detail on stdout"), "{msg}");
});
}
#[cfg(unix)]
#[test]
fn execute_bw_command_rejects_non_utf8_stdout() {
let fake = FakeBw::new().with_garbage(&[0xff, 0xfe]);
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.execute_bw_command(&["status"]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("returned output that is not valid UTF-8"),
"{msg}"
);
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_is_true_when_the_vault_is_unlocked() {
let fake = FakeBw::new(); fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(provider.is_authenticated().unwrap());
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_is_false_when_the_vault_is_locked() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": null, "status": "locked", "authenticated": true
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(!provider.is_authenticated().unwrap());
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_is_false_when_not_logged_in() {
let fake = FakeBw::new().with_failure(1, "", "You are not logged in.");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(!provider.is_authenticated().unwrap());
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_is_false_when_the_vault_is_locked_on_stderr() {
let fake = FakeBw::new().with_failure(1, "", "Vault is locked.");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(!provider.is_authenticated().unwrap());
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_surfaces_unexpected_failures() {
let fake = FakeBw::new().with_failure(1, "", "mystery failure");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert!(provider.is_authenticated().is_err());
});
}
#[cfg(unix)]
#[test]
fn is_authenticated_reports_a_missing_cli() {
let provider = provider_without_bw();
let err = provider.is_authenticated().unwrap_err();
assert!(format!("{err}").contains("is not installed"), "{err}");
}
#[cfg(unix)]
#[test]
fn get_item_as_template_answers_with_the_named_item() {
let fake = FakeBw::new().with_items(json!([
{"id": "it1", "name": "Vault", "type": 1, "login": {"password": "pw"}},
{"id": "it2", "name": "Other", "type": 1}
]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = provider.get_item_as_template("it2").unwrap();
assert_eq!(item["name"], "Other");
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <get> <item> <it2>"),
"{log}"
);
});
}
#[cfg(unix)]
#[test]
fn get_item_as_template_fails_when_the_item_is_missing() {
let fake = FakeBw::new(); fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.get_item_as_template("nope").unwrap_err();
assert!(format!("{err}").contains("Not found"), "{err}");
});
}
#[cfg(unix)]
#[test]
fn create_item_from_template_pipes_the_template_to_create_item() {
use base64::{Engine as _, engine::general_purpose};
let template = json!({
"type": 1, "name": "New",
"notes": "SecretSpec managed secret: New",
"login": {"username": null, "password": "s3cret", "totp": null, "uris": []},
"fields": [], "organizationId": null, "collectionIds": []
});
let fake = FakeBw::new();
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider.create_item_from_template(&template).unwrap();
});
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <create> <item>"),
"{log}"
);
let stdin_line = log
.lines()
.find(|line| line.starts_with(" stdin="))
.expect("create must pipe the item on stdin");
let sent = general_purpose::STANDARD
.decode(stdin_line.trim_start_matches(" stdin="))
.expect("stdin must be base64");
let sent: serde_json::Value = serde_json::from_slice(&sent).unwrap();
assert_eq!(sent["name"], "New");
assert_eq!(sent["login"]["password"], "s3cret");
}
#[cfg(unix)]
#[test]
fn look_up_scope_resolves_an_organization_name_through_the_fake_cli() {
let fake = FakeBw::new().with_organizations(json!([
{"id": "org-1", "name": "DevOps"},
{"id": "org-2", "name": "Security"}
]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("DevOps".to_string()),
..Default::default()
});
let scope = provider.look_up_scope().unwrap();
assert_eq!(scope.organization_id.as_deref(), Some("org-1"));
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <list> <organizations>"),
"{log}"
);
assert!(
log.contains("argv: <--nointeraction> <list> <collections>"),
"{log}"
);
});
}
#[cfg(unix)]
#[test]
fn look_up_scope_fails_when_the_organization_is_not_visible() {
let fake = FakeBw::new().with_organizations(json!([]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("Missing".to_string()),
..Default::default()
});
let err = provider.look_up_scope().unwrap_err();
assert!(err.contains("No organization matching 'Missing'"), "{err}");
});
}
#[cfg(unix)]
#[test]
fn server_check_is_memoized_to_one_status_call_per_provider() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": "https://vault.company.com", "status": "unlocked"
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
server: Some("https://vault.company.com".to_string()),
..Default::default()
});
provider.ensure_server_configured().unwrap();
provider.ensure_server_configured().unwrap();
provider.ensure_server_configured().unwrap();
let status_calls = fake
.invocations()
.lines()
.filter(|line| line.contains("<status>"))
.count();
assert_eq!(status_calls, 1);
});
}
#[cfg(unix)]
#[test]
fn get_answers_with_the_password_of_a_matching_login() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"username": "alice", "password": "pw"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let value = provider.get_from_password_manager("Vault", None).unwrap();
assert_eq!(
value.map(|s| s.expose_secret().to_string()),
Some("pw".to_string())
);
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <list> <items> <--search> <Vault>"),
"{log}"
);
});
}
#[cfg(unix)]
#[test]
fn get_falls_back_from_an_empty_search_to_an_unfiltered_listing() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "API_KEY", "type": 1,
"login": {"password": "casefolded"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let value = provider.get_from_password_manager("api_key", None).unwrap();
assert_eq!(
value.map(|s| s.expose_secret().to_string()),
Some("casefolded".to_string())
);
});
}
#[cfg(unix)]
#[test]
fn get_returns_none_for_an_absent_secret() {
let fake = FakeBw::new();
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert_eq!(
provider
.get_from_password_manager("Vault", None)
.unwrap()
.map(|s| s.expose_secret().to_string()),
None
);
});
}
#[cfg(unix)]
#[test]
fn get_reports_ambiguous_items_instead_of_answering() {
let fake = FakeBw::new().with_items(json!([
{"id": "it1", "name": "Vault", "type": 1, "login": {"password": "a"}},
{"id": "it2", "name": "Vault", "type": 1, "login": {"password": "b"}}
]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.get_from_password_manager("Vault", None)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("are named 'Vault'"), "{msg}");
});
}
#[cfg(unix)]
#[test]
fn get_fails_closed_when_not_authenticated() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": null, "status": "locked", "authenticated": true
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.get_from_password_manager("Vault", None)
.unwrap_err();
assert!(
format!("{err}").contains("Bitwarden authentication required"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn reflect_lists_the_addressed_collection_without_copying_values() {
let fake = FakeBw::new()
.with_organizations(json!([{"id": "org-id", "name": "Acme"}]))
.with_collections(json!([{
"id": "collection-id",
"name": "dev-secrets",
"organizationId": "org-id"
}]))
.with_items(json!([{
"id": "item-id",
"name": "API_KEY",
"type": 1,
"login": {"password": "must-not-appear"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
collection_id: Some("dev-secrets".to_string()),
..Default::default()
});
let declarations = provider
.reflect(DiscoveryContext::new("ignored", "ignored"))
.unwrap();
assert_eq!(declarations.len(), 1);
assert_eq!(
declarations["API_KEY"].description.as_deref(),
Some("API_KEY Bitwarden secret")
);
assert!(!format!("{:?}", declarations["API_KEY"]).contains("must-not-appear"));
let log = fake.invocations();
assert!(log.contains("<status>"), "{log}");
assert!(log.contains("<list> <organizations>"), "{log}");
assert!(log.contains("<list> <collections>"), "{log}");
assert!(
log.contains("<list> <items> <--collectionid> <collection-id>"),
"{log}"
);
});
}
#[cfg(unix)]
#[test]
fn get_through_an_organization_sends_the_resolved_scope_filter() {
let fake = FakeBw::new()
.with_organizations(json!([{"id": "org-1", "name": "DevOps"}]))
.with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"password": "pw"}, "organizationId": "org-1"
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("DevOps".to_string()),
..Default::default()
});
let value = provider.get_from_password_manager("Vault", None).unwrap();
assert_eq!(
value.map(|s| s.expose_secret().to_string()),
Some("pw".to_string())
);
let log = fake.invocations();
assert!(
log.contains("<list> <items> <--search> <Vault> <--organizationid> <org-1>"),
"{log}"
);
});
}
#[cfg(unix)]
#[test]
fn set_updates_an_existing_item_in_place() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"username": "alice", "password": "old"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.set_to_password_manager("Vault", None, &SecretString::new("new".into()))
.unwrap();
});
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <get> <item> <it1>"),
"{log}"
);
assert!(
log.contains("argv: <--nointeraction> <edit> <item> <it1>"),
"{log}"
);
assert!(
!log.contains("<create>"),
"must not create a second item: {log}"
);
let sent = decode_stdin_line(&fake, "edit");
assert_eq!(sent["login"]["password"], "new");
assert_eq!(sent["login"]["username"], "alice");
}
#[cfg(unix)]
#[test]
fn set_creates_a_new_item_when_none_matches() {
let fake = FakeBw::new();
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.set_to_password_manager("Vault", None, &SecretString::new("s3cret".into()))
.unwrap();
});
let log = fake.invocations();
assert!(
log.contains("argv: <--nointeraction> <create> <item>"),
"{log}"
);
let sent = decode_stdin_line(&fake, "create");
assert_eq!(sent["name"], "Vault");
assert_eq!(sent["type"], 1);
assert_eq!(sent["login"]["password"], "s3cret");
}
#[cfg(unix)]
#[test]
fn set_with_an_explicit_field_writes_that_field() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"username": "old-user", "password": "pw"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.set_to_password_manager(
"Vault",
Some("username"),
&SecretString::new("new-user".into()),
)
.unwrap();
});
let sent = decode_stdin_line(&fake, "edit");
assert_eq!(sent["login"]["username"], "new-user");
assert_eq!(sent["login"]["password"], "pw");
}
#[cfg(unix)]
#[test]
fn set_fails_closed_when_not_authenticated() {
let fake = FakeBw::new().with_status(json!({
"serverUrl": null, "status": "locked", "authenticated": true
}));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider
.set_to_password_manager("Vault", None, &SecretString::new("x".into()))
.unwrap_err();
assert!(
format!("{err}").contains("Bitwarden authentication required"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn create_new_item_builds_a_card_item_for_a_card_address() {
let fake = FakeBw::new();
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
default_item_type: Some(BitwardenItemType::Card),
..Default::default()
});
provider
.create_new_item("NewCard", None, "4111 1111 1111 1111")
.unwrap();
});
let sent = decode_stdin_line(&fake, "create");
assert_eq!(sent["name"], "NewCard");
assert_eq!(sent["type"], 3);
assert_eq!(sent["card"]["number"], "4111 1111 1111 1111");
}
#[cfg(unix)]
fn decode_stdin_line(fake: &FakeBw, command: &str) -> serde_json::Value {
use base64::{Engine as _, engine::general_purpose};
let log = fake.invocations();
let cmd_line = log
.lines()
.find(|line| line.contains(&format!("<{command}> <item>")))
.unwrap_or_else(|| panic!("no {command} invocation in log:\n{log}"));
let stdin_line = log
.lines()
.skip_while(|line| *line != cmd_line)
.nth(1)
.and_then(|line| line.strip_prefix(" stdin="))
.unwrap_or_else(|| panic!("no stdin recorded for {command}:\n{log}"));
let bytes = general_purpose::STANDARD
.decode(stdin_line)
.expect("stdin must be base64");
serde_json::from_slice(&bytes).expect("stdin must decode as an item")
}
#[cfg(unix)]
#[test]
fn create_item_from_template_reports_a_missing_cli() {
let provider = provider_without_bw();
let err = provider
.create_item_from_template(&json!({ "name": "Vault" }))
.unwrap_err();
assert!(
format!("{err}").contains("Bitwarden CLI (bw) is not installed"),
"{err}"
);
}
#[cfg(unix)]
#[test]
fn update_item_with_json_reports_a_missing_cli() {
let provider = provider_without_bw();
let err = provider
.update_item_with_json("it1", &json!({ "id": "it1" }))
.unwrap_err();
assert!(
format!("{err}").contains("Bitwarden CLI (bw) is not installed"),
"{err}"
);
}
#[cfg(unix)]
#[test]
fn look_up_scope_reports_an_organizations_listing_failure() {
let fake = FakeBw::new().with_failure(1, "", "boom");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("DevOps".to_string()),
..Default::default()
});
let err = provider.look_up_scope().unwrap_err();
assert!(
err.contains("could not list Bitwarden organizations"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn look_up_scope_reports_a_collections_listing_failure() {
let fake = FakeBw::new().with_failure_on(1, "", "boom", "collections");
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("DevOps".to_string()),
..Default::default()
});
let err = provider.look_up_scope().unwrap_err();
assert!(
err.contains("could not list Bitwarden collections"),
"{err}"
);
});
}
#[cfg(unix)]
#[test]
fn provider_get_resolves_a_convention_address() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"password": "pw"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let addr = Address::convention("myapp", "production", "Vault");
let value = provider.get(addr).unwrap();
assert_eq!(
value.map(|s| s.expose_secret().to_string()),
Some("pw".to_string())
);
});
}
#[cfg(unix)]
#[test]
fn provider_get_resolves_a_native_address_with_a_field() {
let fake = FakeBw::new().with_items(json!([{
"id": "it1", "name": "Vault", "type": 1,
"login": {"username": "alice", "password": "pw"}
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let native = crate::config::NativeAddress {
item: "Vault".to_string(),
field: Some("username".to_string()),
..Default::default()
};
let value = provider.get(Address::Native(&native)).unwrap();
assert_eq!(
value.map(|s| s.expose_secret().to_string()),
Some("alice".to_string())
);
});
}
#[cfg(unix)]
#[test]
fn provider_set_resolves_a_convention_address() {
let fake = FakeBw::new(); fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let addr = Address::convention("myapp", "production", "Vault");
provider
.set(addr, &SecretString::new("s3cret".into()))
.unwrap();
});
assert!(
fake.invocations()
.contains("argv: <--nointeraction> <create> <item>"),
"{}",
fake.invocations()
);
}
#[cfg(unix)]
#[test]
fn create_new_item_with_an_explicit_field_writes_that_field() {
let fake = FakeBw::new();
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
provider
.create_new_item("New", Some("username"), "alice")
.unwrap();
});
let sent = decode_stdin_line(&fake, "create");
assert_eq!(sent["login"]["username"], "alice");
}
#[cfg(unix)]
#[test]
fn get_returns_secure_note_body_when_no_value_field_exists() {
let fake = FakeBw::new().with_items(json!([{
"id": "n1", "name": "Note", "type": 2, "notes": "body", "fields": []
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let unqualified = provider.get_from_password_manager("Note", None).unwrap();
assert_eq!(
unqualified.map(|s| s.expose_secret().to_string()),
Some("body".to_string())
);
let named = provider
.get_from_password_manager("Note", Some("notes"))
.unwrap();
assert_eq!(
named.map(|s| s.expose_secret().to_string()),
Some("body".to_string())
);
});
}
#[cfg(unix)]
#[test]
fn an_item_with_an_unknown_type_fails_to_parse() {
let fake = FakeBw::new().with_items(json!([{
"id": "x", "name": "Bad", "type": 99
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.get_from_password_manager("Bad", None).unwrap_err();
assert!(format!("{err}").contains("Unknown item type"), "{err}");
});
}
#[cfg(unix)]
#[test]
fn an_item_with_an_unknown_field_type_fails_to_parse() {
let fake = FakeBw::new().with_items(json!([{
"id": "x", "name": "Bad", "type": 1,
"login": {"password": "pw"},
"fields": [{"name": "f", "value": "v", "type": 99}]
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let err = provider.get_from_password_manager("Bad", None).unwrap_err();
assert!(format!("{err}").contains("Unknown field type"), "{err}");
});
}
#[test]
fn from_value_rejects_an_unknown_item_type() {
let err = serde_json::from_value::<BitwardenItem>(json!({ "type": 99 })).unwrap_err();
assert!(err.to_string().contains("Unknown item type"), "{err}");
}
#[test]
fn from_value_rejects_an_unknown_field_type() {
let err = serde_json::from_value::<BitwardenItem>(json!({
"type": 1, "fields": [{"name": "f", "type": 99}]
}))
.unwrap_err();
assert!(err.to_string().contains("Unknown field type"), "{err}");
}
#[cfg(unix)]
#[test]
fn look_up_scope_keeps_a_collection_without_an_organization() {
let fake = FakeBw::new().with_collections(json!([{
"id": "col-1", "name": "dev"
}]));
fake.run(|| {
let provider = BitwardenProvider::new(BitwardenConfig {
collection_id: Some("dev".to_string()),
..Default::default()
});
let scope = provider.look_up_scope().unwrap();
assert_eq!(scope.organization_id, None);
assert_eq!(scope.collection_id.as_deref(), Some("col-1"));
});
}
#[test]
fn reading_totp_returns_the_totp_seed() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "l1", "name": "Vault", "type": 1,
"login": { "username": "alice", "password": "pw", "totp": "JBSWY3DPEHPK3PXP" }
}));
assert_eq!(
read_naming_a_field(&provider, &item, "totp").as_deref(),
Some("JBSWY3DPEHPK3PXP"),
);
}
#[test]
fn a_login_without_a_password_defaults_to_the_username() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "l2", "name": "Vault", "type": 1,
"login": { "username": "alice", "password": null }
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("alice"),
);
}
#[test]
fn an_unknown_login_field_that_is_absent_returns_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "l3", "name": "Vault", "type": 1,
"login": { "username": "alice", "password": "pw" }
}));
assert_eq!(read_naming_a_field(&provider, &item, "totp"), None);
assert_eq!(read_naming_a_field(&provider, &item, "api_key"), None);
}
#[test]
fn card_aliases_read_the_same_slot() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "c1", "name": "Card", "type": 3,
"card": { "cardholderName": "Ada L", "number": "4242", "brand": "Visa",
"expMonth": "12", "expYear": "2030", "code": "123" }
}));
for alias in ["code", "cvv", "cvc"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("123"),
"{alias}"
);
}
for alias in ["cardholder", "name"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("Ada L"),
"{alias}",
);
}
for alias in ["expmonth", "exp_month"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("12"),
"{alias}"
);
}
assert_eq!(
read_naming_a_field(&provider, &item, "exp_year").as_deref(),
Some("2030")
);
assert_eq!(
read_naming_a_field(&provider, &item, "brand").as_deref(),
Some("Visa")
);
}
#[test]
fn a_card_read_is_case_insensitive() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "c2", "name": "Card", "type": 3,
"card": { "cardholderName": "Ada L", "number": "4242", "brand": "Visa" }
}));
assert_eq!(
read_naming_a_field(&provider, &item, "BRAND").as_deref(),
Some("Visa")
);
}
#[test]
fn an_unknown_card_field_that_is_absent_returns_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "c3", "name": "Card", "type": 3,
"card": { "cardholderName": "Ada L", "number": "4242" }
}));
assert_eq!(read_naming_a_field(&provider, &item, "exp_month"), None);
assert_eq!(read_naming_a_field(&provider, &item, "cvv"), None);
}
#[test]
fn a_card_without_a_number_falls_back_to_its_value_field() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "c4", "name": "Card", "type": 3,
"card": { "cardholderName": "Ada", "number": null },
"fields": [ { "name": "value", "value": "legacy", "type": 0 } ]
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("legacy"),
);
}
#[test]
fn identity_aliases_read_the_same_slot() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "i1", "name": "Person", "type": 4,
"identity": { "firstName": "Ada", "lastName": "Lovelace", "username": "ada",
"company": "Analytical Engine", "email": "ada@example.test",
"phone": "+1-555-0100" }
}));
for alias in ["firstname", "first_name"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("Ada"),
"{alias}"
);
}
for alias in ["lastname", "last_name"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("Lovelace"),
"{alias}",
);
}
assert_eq!(
read_naming_a_field(&provider, &item, "username").as_deref(),
Some("ada")
);
assert_eq!(
read_naming_a_field(&provider, &item, "company").as_deref(),
Some("Analytical Engine"),
);
assert_eq!(
read_naming_a_field(&provider, &item, "email").as_deref(),
Some("ada@example.test"),
);
assert_eq!(
read_naming_a_field(&provider, &item, "phone").as_deref(),
Some("+1-555-0100")
);
}
#[test]
fn an_unknown_identity_field_that_is_absent_returns_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "i2", "name": "Person", "type": 4,
"identity": { "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.test" }
}));
assert_eq!(read_naming_a_field(&provider, &item, "company"), None);
}
#[test]
fn an_identity_without_builtin_slots_falls_back_to_its_value_field() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "i3", "name": "Person", "type": 4,
"identity": { "firstName": null, "email": null },
"fields": [ { "name": "value", "value": "legacy", "type": 0 } ]
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("legacy"),
);
}
#[test]
fn ssh_aliases_read_the_same_slot() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "s1", "name": "Key", "type": 5,
"sshKey": { "privateKey": "PRIV", "publicKey": "PUB", "keyFingerprint": "SHA256:fp" }
}));
for alias in ["private_key", "privatekey", "private"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("PRIV"),
"{alias}"
);
}
for alias in ["public_key", "publickey", "public"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("PUB"),
"{alias}"
);
}
for alias in ["fingerprint", "key_fingerprint"] {
assert_eq!(
read_naming_a_field(&provider, &item, alias).as_deref(),
Some("SHA256:fp"),
"{alias}",
);
}
}
#[test]
fn a_public_key_request_is_never_answered_with_the_private_key() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "s2", "name": "Key", "type": 5,
"sshKey": { "privateKey": "PRIV", "publicKey": "PUB" }
}));
assert_eq!(
read_naming_a_field(&provider, &item, "public").as_deref(),
Some("PUB")
);
}
#[test]
fn an_explicit_secure_note_field_that_is_absent_returns_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "n1", "name": "Note", "type": 2, "notes": "body",
"fields": [ { "name": "value", "value": "legacy", "type": 0 } ]
}));
assert_eq!(read_naming_a_field(&provider, &item, "missing"), None);
}
#[test]
fn a_secure_note_with_neither_value_field_nor_body_reads_as_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({ "id": "n2", "name": "Empty Note", "type": 2 }));
assert_eq!(read_without_naming_a_field(&provider, &item), None);
}
#[test]
fn an_exact_custom_field_match_wins_over_a_partial_one() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "f1", "name": "API Keys", "type": 1,
"login": { "username": "alice", "password": "pw" },
"fields": [
{ "name": "API_KEY_OLD", "value": "stale", "type": 0 },
{ "name": "API_KEY", "value": "fresh", "type": 0 }
]
}));
assert_eq!(
read_naming_a_field(&provider, &item, "API_KEY").as_deref(),
Some("fresh")
);
}
#[test]
fn a_partial_custom_field_match_resolves_to_the_first_containing_field() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "f2", "name": "Notes", "type": 2, "notes": "body",
"fields": [ { "name": "My API Key", "value": "sk-123", "type": 0 } ]
}));
assert_eq!(
read_naming_a_field(&provider, &item, "api").as_deref(),
Some("sk-123")
);
}
#[test]
fn adding_a_custom_field_creates_it_in_the_fields_array() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let mut item_json = serde_json::json!({ "id": "t1", "name": "Item", "type": 1 });
provider
.update_custom_field_in_json(&mut item_json, "api_key", "sk-1")
.unwrap();
let fields = item_json["fields"]
.as_array()
.expect("a fields array is created");
assert_eq!(fields.len(), 1);
assert_eq!(fields[0]["name"].as_str(), Some("api_key"));
assert_eq!(fields[0]["value"].as_str(), Some("sk-1"));
}
#[test]
fn secret_like_field_names_are_stored_hidden_and_others_as_text() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let mut item_json = serde_json::json!({ "id": "t2", "name": "Item", "type": 1 });
provider
.update_custom_field_in_json(&mut item_json, "api_key", "sk")
.unwrap();
provider
.update_custom_field_in_json(&mut item_json, "display_name", "prod")
.unwrap();
let fields = item_json["fields"].as_array().unwrap();
let by_name = |n: &str| fields.iter().find(|f| f["name"] == n).unwrap();
assert_eq!(
by_name("api_key")["type"].as_u64(),
Some(1),
"api_key holds a secret"
);
assert_eq!(
by_name("display_name")["type"].as_u64(),
Some(0),
"display_name does not"
);
}
#[test]
fn a_non_array_fields_value_is_reported_not_ignored() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let mut item_json =
serde_json::json!({ "id": "t3", "name": "Item", "type": 1, "fields": "oops" });
let err = provider
.update_custom_field_in_json(&mut item_json, "x", "y")
.expect_err("fields must be an array");
assert!(err.to_string().contains("Invalid fields array"), "{err}");
}
#[test]
fn convention_address_names_the_secret_key_as_the_item() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let address = provider
.convention_address("project", "default", "DATABASE_URL")
.unwrap();
assert_eq!(address.item, "DATABASE_URL");
}
#[test]
fn items_support_field_coordinates() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
assert_eq!(provider.supported_coords(), &["field"]);
}
#[test]
fn placement_from_a_scope_carries_its_organization_and_collection() {
let scoped = VaultScope {
organization_id: Some("org-1".to_string()),
collection_id: Some("col-2".to_string()),
};
let placement = ItemPlacement::from(&scoped);
assert_eq!(placement.organization_id.as_deref(), Some("org-1"));
assert_eq!(
placement.collection_ids.as_deref(),
Some(&["col-2".to_string()][..]),
);
let org_only = VaultScope {
organization_id: Some("org-1".to_string()),
collection_id: None,
};
let placement = ItemPlacement::from(&org_only);
assert_eq!(placement.organization_id.as_deref(), Some("org-1"));
assert_eq!(placement.collection_ids, None);
}
#[test]
fn an_unscoped_provider_places_items_nowhere() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
with_clean_env(|| {
let placement = provider.item_placement().expect("unscoped: no CLI call");
assert_eq!(placement.organization_id, None);
assert_eq!(placement.collection_ids, None);
});
}
#[test]
fn the_environment_overrides_the_configured_scope() {
let provider = BitwardenProvider::new(BitwardenConfig {
organization_id: Some("cfg-org".to_string()),
collection_id: Some("cfg-col".to_string()),
..Default::default()
});
with_clean_env(|| {
assert_eq!(provider.requested_org().as_deref(), Some("cfg-org"));
assert_eq!(provider.requested_collection().as_deref(), Some("cfg-col"));
});
with_env("BITWARDEN_ORGANIZATION", "env-org", || {
assert_eq!(provider.requested_org().as_deref(), Some("env-org"));
});
with_env("BITWARDEN_COLLECTION", "env-col", || {
assert_eq!(provider.requested_collection().as_deref(), Some("env-col"));
});
}
#[test]
fn resolved_item_type_prefers_the_environment_and_falls_back_to_config() {
let provider = BitwardenProvider::new(BitwardenConfig {
default_item_type: Some(BitwardenItemType::Card),
..Default::default()
});
with_clean_env(|| {
assert_eq!(
provider.resolved_item_type().unwrap(),
Some(BitwardenItemType::Card)
);
});
with_env("BITWARDEN_DEFAULT_TYPE", "ssh", || {
assert_eq!(
provider.resolved_item_type().unwrap(),
Some(BitwardenItemType::SshKey)
);
});
let bare = BitwardenProvider::new(BitwardenConfig::default());
with_clean_env(|| assert_eq!(bare.resolved_item_type().unwrap(), None));
}
#[test]
fn every_item_type_round_trips_through_as_str() {
for item_type in ALL_ITEM_TYPES {
assert_eq!(
BitwardenItemType::from_str(item_type.as_str()),
Some(item_type)
);
}
}
#[test]
fn field_types_have_readable_names() {
assert_eq!(BitwardenFieldType::Text.as_str(), "text");
assert_eq!(BitwardenFieldType::Hidden.as_str(), "hidden");
assert_eq!(BitwardenFieldType::Boolean.as_str(), "boolean");
assert_eq!(BitwardenFieldType::Linked.as_str(), "linked");
}
#[test]
fn describe_org_prefers_the_listed_name_and_falls_back_to_the_id() {
let orgs = vec![BitwardenNamedObject {
id: "o1".to_string(),
name: "Acme Inc".to_string(),
organization_id: None,
}];
assert_eq!(describe_org(Some("o1"), &orgs), "'Acme Inc' (o1)");
assert_eq!(describe_org(Some("unknown"), &orgs), "'unknown'");
assert_eq!(describe_org(None, &orgs), "your personal vault");
}
#[test]
fn list_organizations_names_them_or_says_none_are_visible() {
let none = list_organizations(&[]);
assert!(none.contains("listed no organizations"), "{none}");
let orgs = vec![BitwardenNamedObject {
id: "o1".to_string(),
name: "Acme Inc".to_string(),
organization_id: None,
}];
let listing = list_organizations(&orgs);
assert!(listing.contains("Available organizations:"), "{listing}");
assert!(listing.contains("Acme Inc (o1)"), "{listing}");
}
#[test]
fn reading_password_explicitly_returns_the_login_password() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "l5", "name": "Vault", "type": 1,
"login": { "username": "alice", "password": "pw" }
}));
assert_eq!(
read_naming_a_field(&provider, &item, "password").as_deref(),
Some("pw")
);
}
#[test]
fn a_login_with_neither_password_nor_username_falls_back_to_custom_fields() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "l6", "name": "Vault", "type": 1,
"login": { "username": null, "password": null },
"fields": [ { "name": "value", "value": "legacy", "type": 0 } ]
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("legacy"),
);
}
#[test]
fn an_item_type_without_its_data_object_falls_back_to_custom_fields() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
for (item_type, id) in [
(BitwardenItemType::Login, "no-login"),
(BitwardenItemType::Card, "no-card"),
(BitwardenItemType::Identity, "no-identity"),
(BitwardenItemType::SshKey, "no-ssh"),
] {
let item = item_from(json!({
"id": id, "name": "Bare", "type": item_type.to_u8(),
"fields": [ { "name": "value", "value": "legacy", "type": 0 } ]
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("legacy"),
"{item_type:?}",
);
}
}
#[test]
fn a_non_builtin_card_field_reads_a_custom_field_or_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "c5", "name": "Card", "type": 3,
"card": { "cardholderName": "Ada", "number": "4242" },
"fields": [ { "name": "api_token", "value": "tok-1", "type": 0 } ]
}));
assert_eq!(
read_naming_a_field(&provider, &item, "api_token").as_deref(),
Some("tok-1")
);
assert_eq!(read_naming_a_field(&provider, &item, "missing"), None);
}
#[test]
fn a_non_builtin_identity_field_reads_a_custom_field_or_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "i4", "name": "Person", "type": 4,
"identity": { "firstName": "Ada", "email": "ada@example.test" },
"fields": [ { "name": "employee_id", "value": "EMP001", "type": 0 } ]
}));
assert_eq!(
read_naming_a_field(&provider, &item, "employee_id").as_deref(),
Some("EMP001"),
);
assert_eq!(read_naming_a_field(&provider, &item, "missing"), None);
}
#[test]
fn a_non_builtin_ssh_field_reads_a_custom_field_or_nothing() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "s3", "name": "Key", "type": 5,
"sshKey": { "privateKey": "PRIV", "publicKey": "PUB" },
"fields": [ { "name": "passphrase", "value": "secret", "type": 0 } ]
}));
assert_eq!(
read_naming_a_field(&provider, &item, "passphrase").as_deref(),
Some("secret"),
);
assert_eq!(read_naming_a_field(&provider, &item, "missing"), None);
}
#[test]
fn an_identity_without_an_email_defaults_to_the_username() {
let provider = BitwardenProvider::new(BitwardenConfig::default());
let item = item_from(json!({
"id": "i5", "name": "Person", "type": 4,
"identity": { "firstName": "Ada", "email": null, "username": "ada" }
}));
assert_eq!(
read_without_naming_a_field(&provider, &item).as_deref(),
Some("ada")
);
}
#[test]
fn an_ambiguous_organization_name_is_reported_with_a_remedy() {
let orgs = vec![
BitwardenNamedObject {
id: "o1".to_string(),
name: "Acme Inc".to_string(),
organization_id: None,
},
BitwardenNamedObject {
id: "o2".to_string(),
name: "Acme Inc".to_string(),
organization_id: None,
},
];
let err = resolve_organization(&orgs, "Acme Inc").expect_err("duplicate name");
assert!(err.contains("ambiguous"), "{err}");
assert!(err.contains("UUID"), "{err}");
}
}