use anyhow::Context as _; use serde::Serialize; use std::fmt::{Display, Formatter, Result as FmtResult}; use std::io; use std::io::prelude::Write; use url::Url; const MISSING_CONFIG_HELP: &str = "Before using rbw, you must configure the email address you would like to \ use to log in to the server by running:\n\n \ rbw config set email \n\n\ Additionally, if you are using a self-hosted installation, you should \ run:\n\n \ rbw config set base_url \n\n\ and, if your server has a non-default identity url:\n\n \ rbw config set identity_url \n"; #[derive(Debug, Clone)] pub enum Needle { Name(String), Uri(Url), Uuid(uuid::Uuid), } impl Display for Needle { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { let value = match &self { Self::Name(name) => name.clone(), Self::Uri(uri) => uri.to_string(), Self::Uuid(uuid) => uuid.to_string(), }; write!(f, "{value}") } } #[allow(clippy::unnecessary_wraps)] pub fn parse_needle(arg: &str) -> Result { if let Ok(uuid) = uuid::Uuid::parse_str(arg) { return Ok(Needle::Uuid(uuid)); } if let Ok(url) = Url::parse(arg) { return Ok(Needle::Uri(url)); } Ok(Needle::Name(arg.to_string())) } #[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedCipher { id: String, folder: Option, name: String, data: DecryptedData, fields: Vec, notes: Option, history: Vec, } impl DecryptedCipher { fn display_short(&self, desc: &str, clipboard: bool) -> bool { match &self.data { DecryptedData::Login { password, .. } => { password.as_ref().map_or_else( || { eprintln!("entry for '{desc}' had no password"); false }, |password| val_display_or_store(clipboard, password), ) } DecryptedData::Card { number, .. } => { number.as_ref().map_or_else( || { eprintln!("entry for '{desc}' had no card number"); false }, |number| val_display_or_store(clipboard, number), ) } DecryptedData::Identity { title, first_name, middle_name, last_name, .. } => { let names: Vec<_> = [title, first_name, middle_name, last_name] .iter() .copied() .flatten() .cloned() .collect(); if names.is_empty() { eprintln!("entry for '{desc}' had no name"); false } else { val_display_or_store(clipboard, &names.join(" ")) } } DecryptedData::SecureNote {} => self.notes.as_ref().map_or_else( || { eprintln!("entry for '{desc}' had no notes"); false }, |notes| val_display_or_store(clipboard, notes), ), } } fn display_field(&self, desc: &str, field: &str, clipboard: bool) { let field = field.to_lowercase(); let field = field.as_str(); match &self.data { DecryptedData::Login { username, totp, uris, .. } => match field { "notes" => { if let Some(notes) = &self.notes { val_display_or_store(clipboard, notes); } } "username" | "user" => { if let Some(username) = &username { val_display_or_store(clipboard, username); } } "totp" | "code" => { if let Some(totp) = totp { match generate_totp(totp) { Ok(code) => { val_display_or_store(clipboard, &code); } Err(e) => { eprintln!("{e}"); } } } } "uris" | "urls" | "sites" => { if let Some(uris) = uris { let uri_strs: Vec<_> = uris .iter() .map(|uri| uri.uri.to_string()) .collect(); val_display_or_store(clipboard, &uri_strs.join("\n")); } } "password" => { self.display_short(desc, clipboard); } _ => { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { val_display_or_store( clipboard, f.value.as_deref().unwrap_or(""), ); break; } } } } }, DecryptedData::Card { cardholder_name, brand, exp_month, exp_year, code, .. } => match field { "number" | "card" => { self.display_short(desc, clipboard); } "exp" => { if let (Some(month), Some(year)) = (exp_month, exp_year) { val_display_or_store( clipboard, &format!("{month}/{year}"), ); } } "exp_month" | "month" => { if let Some(exp_month) = exp_month { val_display_or_store(clipboard, exp_month); } } "exp_year" | "year" => { if let Some(exp_year) = exp_year { val_display_or_store(clipboard, exp_year); } } "cvv" => { if let Some(code) = code { val_display_or_store(clipboard, code); } } "name" | "cardholder" => { if let Some(cardholder_name) = cardholder_name { val_display_or_store(clipboard, cardholder_name); } } "brand" | "type" => { if let Some(brand) = brand { val_display_or_store(clipboard, brand); } } "notes" => { if let Some(notes) = &self.notes { val_display_or_store(clipboard, notes); } } _ => { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { val_display_or_store( clipboard, f.value.as_deref().unwrap_or(""), ); break; } } } } }, DecryptedData::Identity { address1, address2, address3, city, state, postal_code, country, phone, email, ssn, license_number, passport_number, username, .. } => match field { "name" => { self.display_short(desc, clipboard); } "email" => { if let Some(email) = email { val_display_or_store(clipboard, email); } } "address" => { let mut strs = vec![]; if let Some(address1) = address1 { strs.push(address1.clone()); } if let Some(address2) = address2 { strs.push(address2.clone()); } if let Some(address3) = address3 { strs.push(address3.clone()); } if !strs.is_empty() { val_display_or_store(clipboard, &strs.join("\n")); } } "city" => { if let Some(city) = city { val_display_or_store(clipboard, city); } } "state" => { if let Some(state) = state { val_display_or_store(clipboard, state); } } "postcode" | "zipcode" | "zip" => { if let Some(postal_code) = postal_code { val_display_or_store(clipboard, postal_code); } } "country" => { if let Some(country) = country { val_display_or_store(clipboard, country); } } "phone" => { if let Some(phone) = phone { val_display_or_store(clipboard, phone); } } "ssn" => { if let Some(ssn) = ssn { val_display_or_store(clipboard, ssn); } } "license" => { if let Some(license_number) = license_number { val_display_or_store(clipboard, license_number); } } "passport" => { if let Some(passport_number) = passport_number { val_display_or_store(clipboard, passport_number); } } "username" => { if let Some(username) = username { val_display_or_store(clipboard, username); } } "notes" => { if let Some(notes) = &self.notes { val_display_or_store(clipboard, notes); } } _ => { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { val_display_or_store( clipboard, f.value.as_deref().unwrap_or(""), ); break; } } } } }, DecryptedData::SecureNote {} => match field { "note" | "notes" => { self.display_short(desc, clipboard); } _ => { for f in &self.fields { if let Some(name) = &f.name { if name.to_lowercase().as_str().contains(field) { val_display_or_store( clipboard, f.value.as_deref().unwrap_or(""), ); break; } } } } }, } } fn display_long(&self, desc: &str, clipboard: bool) { match &self.data { DecryptedData::Login { username, totp, uris, .. } => { let mut displayed = self.display_short(desc, clipboard); displayed |= display_field("Username", username.as_deref(), clipboard); displayed |= display_field("TOTP Secret", totp.as_deref(), clipboard); if let Some(uris) = uris { for uri in uris { displayed |= display_field("URI", Some(&uri.uri), clipboard); let match_type = uri.match_type.map(|ty| format!("{ty}")); displayed |= display_field( "Match type", match_type.as_deref(), clipboard, ); } } for field in &self.fields { displayed |= display_field( field.name.as_deref().unwrap_or("(null)"), Some(field.value.as_deref().unwrap_or("")), clipboard, ); } if let Some(notes) = &self.notes { if displayed { println!(); } println!("{notes}"); } } DecryptedData::Card { cardholder_name, brand, exp_month, exp_year, code, .. } => { let mut displayed = self.display_short(desc, clipboard); if let (Some(exp_month), Some(exp_year)) = (exp_month, exp_year) { println!("Expiration: {exp_month}/{exp_year}"); displayed = true; } displayed |= display_field("CVV", code.as_deref(), clipboard); displayed |= display_field( "Name", cardholder_name.as_deref(), clipboard, ); displayed |= display_field("Brand", brand.as_deref(), clipboard); if let Some(notes) = &self.notes { if displayed { println!(); } println!("{notes}"); } } DecryptedData::Identity { address1, address2, address3, city, state, postal_code, country, phone, email, ssn, license_number, passport_number, username, .. } => { let mut displayed = self.display_short(desc, clipboard); displayed |= display_field("Address", address1.as_deref(), clipboard); displayed |= display_field("Address", address2.as_deref(), clipboard); displayed |= display_field("Address", address3.as_deref(), clipboard); displayed |= display_field("City", city.as_deref(), clipboard); displayed |= display_field("State", state.as_deref(), clipboard); displayed |= display_field( "Postcode", postal_code.as_deref(), clipboard, ); displayed |= display_field("Country", country.as_deref(), clipboard); displayed |= display_field("Phone", phone.as_deref(), clipboard); displayed |= display_field("Email", email.as_deref(), clipboard); displayed |= display_field("SSN", ssn.as_deref(), clipboard); displayed |= display_field( "License", license_number.as_deref(), clipboard, ); displayed |= display_field( "Passport", passport_number.as_deref(), clipboard, ); displayed |= display_field("Username", username.as_deref(), clipboard); if let Some(notes) = &self.notes { if displayed { println!(); } println!("{notes}"); } } DecryptedData::SecureNote {} => { self.display_short(desc, clipboard); } } } fn display_name(&self) -> String { match &self.data { DecryptedData::Login { username, .. } => { username.as_ref().map_or_else( || self.name.clone(), |username| format!("{}@{}", username, self.name), ) } _ => self.name.clone(), } } fn display_json(&self, desc: &str) -> anyhow::Result<()> { serde_json::to_writer_pretty(std::io::stdout(), &self) .context(format!("failed to write entry '{desc}' to stdout"))?; println!(); Ok(()) } fn exact_match( &self, needle: &Needle, username: Option<&str>, folder: Option<&str>, try_match_folder: bool, ) -> bool { match needle { Needle::Name(name) => { if &self.name != name { return false; } } Needle::Uri(given_uri) => { match &self.data { DecryptedData::Login { uris: Some(uris), .. } => { if !uris.iter().any(|uri| uri.matches_url(given_uri)) { return false; } } _ => { // not sure what else to do here, but open to suggestions return false; } } } Needle::Uuid(uuid) => { if uuid::Uuid::parse_str(&self.id) != Ok(*uuid) { return false; } } } if let Some(given_username) = username { match &self.data { DecryptedData::Login { username: Some(found_username), .. } => { if given_username != found_username { return false; } } _ => { // not sure what else to do here, but open to suggestions return false; } } } if try_match_folder { if let Some(given_folder) = folder { if let Some(folder) = &self.folder { if given_folder != folder { return false; } } else { return false; } } else if self.folder.is_some() { return false; } } true } fn partial_match( &self, name: &str, username: Option<&str>, folder: Option<&str>, try_match_folder: bool, ) -> bool { if !self.name.contains(name) { return false; } if let Some(given_username) = username { match &self.data { DecryptedData::Login { username: Some(found_username), .. } => { if !found_username.contains(given_username) { return false; } } _ => { // not sure what else to do here, but open to suggestions return false; } } } if try_match_folder { if let Some(given_folder) = folder { if let Some(folder) = &self.folder { if !folder.contains(given_folder) { return false; } } else { return false; } } else if self.folder.is_some() { return false; } } true } } fn val_display_or_store(clipboard: bool, password: &str) -> bool { if clipboard { match clipboard_store(password) { Ok(()) => true, Err(e) => { eprintln!("{e}"); false } } } else { println!("{password}"); true } } #[derive(Debug, Clone, Serialize)] #[serde(untagged)] #[cfg_attr(test, derive(Eq, PartialEq))] enum DecryptedData { Login { username: Option, password: Option, totp: Option, uris: Option>, }, Card { cardholder_name: Option, number: Option, brand: Option, exp_month: Option, exp_year: Option, code: Option, }, Identity { title: Option, first_name: Option, middle_name: Option, last_name: Option, address1: Option, address2: Option, address3: Option, city: Option, state: Option, postal_code: Option, country: Option, phone: Option, email: Option, ssn: Option, license_number: Option, passport_number: Option, username: Option, }, SecureNote, } #[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedField { name: Option, value: Option, } #[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedHistoryEntry { last_used_date: String, password: String, } #[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedUri { uri: String, match_type: Option, } impl DecryptedUri { fn matches_url(&self, url: &Url) -> bool { match self.match_type.unwrap_or(rbw::api::UriMatchType::Domain) { rbw::api::UriMatchType::Domain => { let Some(given_domain_port) = domain_port(url) else { return false; }; if let Ok(self_url) = url::Url::parse(&self.uri) { if let Some(self_domain_port) = domain_port(&self_url) { if self_url.scheme() == url.scheme() && (self_domain_port == given_domain_port || given_domain_port.ends_with(&format!( ".{self_domain_port}" ))) { return true; } } } self.uri == given_domain_port || given_domain_port.ends_with(&format!(".{}", self.uri)) } rbw::api::UriMatchType::Host => { let Some(given_host_port) = host_port(url) else { return false; }; if let Ok(self_url) = url::Url::parse(&self.uri) { if let Some(self_host_port) = host_port(&self_url) { if self_url.scheme() == url.scheme() && self_host_port == given_host_port { return true; } } } self.uri == given_host_port } rbw::api::UriMatchType::StartsWith => { url.to_string().starts_with(&self.uri) } rbw::api::UriMatchType::Exact => url.to_string() == self.uri, rbw::api::UriMatchType::RegularExpression => { let Ok(rx) = regex::Regex::new(&self.uri) else { return false; }; rx.is_match(url.as_ref()) } rbw::api::UriMatchType::Never => false, } } } fn host_port(url: &Url) -> Option { let host = url.host_str()?; Some( url.port().map_or_else( || host.to_string(), |port| format!("{host}:{port}"), ), ) } fn domain_port(url: &Url) -> Option { let domain = url.domain()?; Some(url.port().map_or_else( || domain.to_string(), |port| format!("{domain}:{port}"), )) } enum ListField { Name, Id, User, Folder, } impl std::convert::TryFrom<&String> for ListField { type Error = anyhow::Error; fn try_from(s: &String) -> anyhow::Result { Ok(match s.as_str() { "name" => Self::Name, "id" => Self::Id, "user" => Self::User, "folder" => Self::Folder, _ => return Err(anyhow::anyhow!("unknown field {}", s)), }) } } const HELP_PW: &str = r" # The first line of this file will be the password, and the remainder of the # file (after any blank lines after the password) will be stored as a note. # Lines with leading # will be ignored. "; const HELP_NOTES: &str = r" # The content of this file will be stored as a note. # Lines with leading # will be ignored. "; pub fn config_show() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; serde_json::to_writer_pretty(std::io::stdout(), &config) .context("failed to write config to stdout")?; println!(); Ok(()) } pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { let mut config = rbw::config::Config::load() .unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = Some(value.to_string()), "base_url" => config.base_url = Some(value.to_string()), "identity_url" => config.identity_url = Some(value.to_string()), "notifications_url" => { config.notifications_url = Some(value.to_string()); } "client_cert_path" => { config.client_cert_path = Some(std::path::PathBuf::from(value.to_string())); } "lock_timeout" => { let timeout = value .parse() .context("failed to parse value for lock_timeout")?; if timeout == 0 { log::error!("lock_timeout must be greater than 0"); } else { config.lock_timeout = timeout; } } "sync_interval" => { let interval = value .parse() .context("failed to parse value for sync_interval")?; config.sync_interval = interval; } "pinentry" => config.pinentry = value.to_string(), _ => return Err(anyhow::anyhow!("invalid config key: {}", key)), } config.save()?; // drop in-memory keys, since they will be different if the email or url // changed. not using lock() because we don't want to require the agent to // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. stop_agent()?; Ok(()) } pub fn config_unset(key: &str) -> anyhow::Result<()> { let mut config = rbw::config::Config::load() .unwrap_or_else(|_| rbw::config::Config::new()); match key { "email" => config.email = None, "base_url" => config.base_url = None, "identity_url" => config.identity_url = None, "notifications_url" => config.notifications_url = None, "client_cert_path" => config.client_cert_path = None, "lock_timeout" => { config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), _ => return Err(anyhow::anyhow!("invalid config key: {}", key)), } config.save()?; // drop in-memory keys, since they will be different if the email or url // changed. not using lock() because we don't want to require the agent to // be running (since this may be the user running `rbw config set // base_url` as the first operation), and stop_agent() already handles the // agent not running case gracefully. stop_agent()?; Ok(()) } fn clipboard_store(val: &str) -> anyhow::Result<()> { ensure_agent()?; crate::actions::clipboard_store(val)?; Ok(()) } pub fn register() -> anyhow::Result<()> { ensure_agent()?; crate::actions::register()?; Ok(()) } pub fn login() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; Ok(()) } pub fn unlock() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; crate::actions::unlock()?; Ok(()) } pub fn unlocked() -> anyhow::Result<()> { ensure_agent()?; crate::actions::unlocked()?; Ok(()) } pub fn sync() -> anyhow::Result<()> { ensure_agent()?; crate::actions::login()?; crate::actions::sync()?; Ok(()) } pub fn list(fields: &[String]) -> anyhow::Result<()> { let fields: Vec = fields .iter() .map(std::convert::TryFrom::try_from) .collect::>()?; unlock()?; let db = load_db()?; let mut ciphers: Vec = db .entries .iter() .map(decrypt_cipher) .collect::>()?; ciphers.sort_unstable_by(|a, b| a.name.cmp(&b.name)); for cipher in ciphers { let values: Vec = fields .iter() .map(|field| match field { ListField::Name => cipher.name.clone(), ListField::Id => cipher.id.clone(), ListField::User => match &cipher.data { DecryptedData::Login { username, .. } => { username.as_ref().map_or_else( String::new, std::string::ToString::to_string, ) } _ => String::new(), }, ListField::Folder => cipher.folder.as_ref().map_or_else( String::new, std::string::ToString::to_string, ), }) .collect(); // write to stdout but don't panic when pipe get's closed // this happens when piping stdout in a shell match writeln!(&mut io::stdout(), "{}", values.join("\t")) { Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), res => res, }?; } Ok(()) } pub fn get( needle: &Needle, user: Option<&str>, folder: Option<&str>, field: Option<&str>, full: bool, raw: bool, clipboard: bool, ) -> anyhow::Result<()> { unlock()?; let db = load_db()?; let desc = format!( "{}{}", user.map_or_else(String::new, |s| format!("{s}@")), needle ); let (_, decrypted) = find_entry(&db, needle, user, folder) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if raw { decrypted.display_json(&desc)?; } else if full { decrypted.display_long(&desc, clipboard); } else if let Some(field) = field { decrypted.display_field(&desc, field, clipboard); } else { decrypted.display_short(&desc, clipboard); } Ok(()) } pub fn code( name: &str, user: Option<&str>, folder: Option<&str>, clipboard: bool, ) -> anyhow::Result<()> { unlock()?; let db = load_db()?; let desc = format!( "{}{}", user.map_or_else(String::new, |s| format!("{s}@")), name ); let (_, decrypted) = find_entry(&db, &Needle::Name(name.to_string()), user, folder) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let DecryptedData::Login { totp, .. } = decrypted.data { if let Some(totp) = totp { val_display_or_store(clipboard, &generate_totp(&totp)?); } else { return Err(anyhow::anyhow!( "entry does not contain a totp secret" )); } } else { return Err(anyhow::anyhow!("not a login entry")); } Ok(()) } pub fn add( name: &str, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, ) -> anyhow::Result<()> { unlock()?; let mut db = load_db()?; // unwrap is safe here because the call to unlock above is guaranteed to // populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); let name = crate::actions::encrypt(name, None)?; let username = username .map(|username| crate::actions::encrypt(username, None)) .transpose()?; let contents = rbw::edit::edit("", HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password .map(|password| crate::actions::encrypt(&password, None)) .transpose()?; let notes = notes .map(|notes| crate::actions::encrypt(¬es, None)) .transpose()?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { uri: crate::actions::encrypt(&uri.0, None)?, match_type: uri.1, }) }) .collect::>()?; let mut folder_id = None; if let Some(folder_name) = folder { let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; if let Some(new_access_token) = new_access_token { access_token = new_access_token.clone(); db.access_token = Some(new_access_token); save_db(&db)?; } let folders: Vec<(String, String)> = folders .iter() .cloned() .map(|(id, name)| Ok((id, crate::actions::decrypt(&name, None)?))) .collect::>()?; for (id, name) in folders { if name == folder_name { folder_id = Some(id); } } if folder_id.is_none() { let (new_access_token, id) = rbw::actions::create_folder( &access_token, refresh_token, &crate::actions::encrypt(folder_name, None)?, )?; if let Some(new_access_token) = new_access_token { access_token = new_access_token.clone(); db.access_token = Some(new_access_token); save_db(&db)?; } folder_id = Some(id); } } if let (Some(access_token), ()) = rbw::actions::add( &access_token, refresh_token, &name, &rbw::db::EntryData::Login { username, password, uris, totp: None, }, notes.as_deref(), folder_id.as_deref(), )? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; Ok(()) } pub fn generate( name: Option<&str>, username: Option<&str>, uris: &[(String, Option)], folder: Option<&str>, len: usize, ty: rbw::pwgen::Type, ) -> anyhow::Result<()> { let password = rbw::pwgen::pwgen(ty, len); println!("{password}"); if let Some(name) = name { unlock()?; let mut db = load_db()?; // unwrap is safe here because the call to unlock above is guaranteed // to populate these or error let mut access_token = db.access_token.as_ref().unwrap().clone(); let refresh_token = db.refresh_token.as_ref().unwrap(); let name = crate::actions::encrypt(name, None)?; let username = username .map(|username| crate::actions::encrypt(username, None)) .transpose()?; let password = crate::actions::encrypt(&password, None)?; let uris: Vec<_> = uris .iter() .map(|uri| { Ok(rbw::db::Uri { uri: crate::actions::encrypt(&uri.0, None)?, match_type: uri.1, }) }) .collect::>()?; let mut folder_id = None; if let Some(folder_name) = folder { let (new_access_token, folders) = rbw::actions::list_folders(&access_token, refresh_token)?; if let Some(new_access_token) = new_access_token { access_token = new_access_token.clone(); db.access_token = Some(new_access_token); save_db(&db)?; } let folders: Vec<(String, String)> = folders .iter() .cloned() .map(|(id, name)| { Ok((id, crate::actions::decrypt(&name, None)?)) }) .collect::>()?; for (id, name) in folders { if name == folder_name { folder_id = Some(id); } } if folder_id.is_none() { let (new_access_token, id) = rbw::actions::create_folder( &access_token, refresh_token, &crate::actions::encrypt(folder_name, None)?, )?; if let Some(new_access_token) = new_access_token { access_token = new_access_token.clone(); db.access_token = Some(new_access_token); save_db(&db)?; } folder_id = Some(id); } } if let (Some(access_token), ()) = rbw::actions::add( &access_token, refresh_token, &name, &rbw::db::EntryData::Login { username, password: Some(password), uris, totp: None, }, None, folder_id.as_deref(), )? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; } Ok(()) } pub fn edit( name: &str, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<()> { unlock()?; let mut db = load_db()?; let access_token = db.access_token.as_ref().unwrap(); let refresh_token = db.refresh_token.as_ref().unwrap(); let desc = format!( "{}{}", username.map_or_else(String::new, |s| format!("{s}@")), name ); let (entry, decrypted) = find_entry(&db, &Needle::Name(name.to_string()), username, folder) .with_context(|| format!("couldn't find entry for '{desc}'"))?; let (data, fields, notes, history) = match &decrypted.data { DecryptedData::Login { password, .. } => { let mut contents = format!("{}\n", password.as_deref().unwrap_or("")); if let Some(notes) = decrypted.notes { contents.push_str(&format!("\n{notes}\n")); } let contents = rbw::edit::edit(&contents, HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password .map(|password| { crate::actions::encrypt( &password, entry.org_id.as_deref(), ) }) .transpose()?; let notes = notes .map(|notes| { crate::actions::encrypt(¬es, entry.org_id.as_deref()) }) .transpose()?; let mut history = entry.history.clone(); let rbw::db::EntryData::Login { username: entry_username, password: entry_password, uris: entry_uris, totp: entry_totp, } = &entry.data else { unreachable!(); }; if let Some(prev_password) = entry_password.clone() { let new_history_entry = rbw::db::HistoryEntry { last_used_date: format!( "{}", humantime::format_rfc3339( std::time::SystemTime::now() ) ), password: prev_password, }; history.insert(0, new_history_entry); } let data = rbw::db::EntryData::Login { username: entry_username.clone(), password, uris: entry_uris.clone(), totp: entry_totp.clone(), }; (data, entry.fields, notes, history) } DecryptedData::SecureNote {} => { let data = rbw::db::EntryData::SecureNote {}; let editor_content = decrypted.notes.map_or_else( || "\n".to_string(), |notes| format!("{notes}\n"), ); let contents = rbw::edit::edit(&editor_content, HELP_NOTES)?; // prepend blank line to be parsed as pw by `parse_editor` let (_, notes) = parse_editor(&format!("\n{contents}\n")); let notes = notes .map(|notes| { crate::actions::encrypt(¬es, entry.org_id.as_deref()) }) .transpose()?; (data, entry.fields, notes, entry.history) } _ => { return Err(anyhow::anyhow!( "modifications are only supported for login and note entries" )); } }; if let (Some(access_token), ()) = rbw::actions::edit( access_token, refresh_token, &entry.id, entry.org_id.as_deref(), &entry.name, &data, &fields, notes.as_deref(), entry.folder_id.as_deref(), &history, )? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; Ok(()) } pub fn remove( name: &str, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<()> { unlock()?; let mut db = load_db()?; let access_token = db.access_token.as_ref().unwrap(); let refresh_token = db.refresh_token.as_ref().unwrap(); let desc = format!( "{}{}", username.map_or_else(String::new, |s| format!("{s}@")), name ); let (entry, _) = find_entry(&db, &Needle::Name(name.to_string()), username, folder) .with_context(|| format!("couldn't find entry for '{desc}'"))?; if let (Some(access_token), ()) = rbw::actions::remove(access_token, refresh_token, &entry.id)? { db.access_token = Some(access_token); save_db(&db)?; } crate::actions::sync()?; Ok(()) } pub fn history( name: &str, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<()> { unlock()?; let db = load_db()?; let desc = format!( "{}{}", username.map_or_else(String::new, |s| format!("{s}@")), name ); let (_, decrypted) = find_entry(&db, &Needle::Name(name.to_string()), username, folder) .with_context(|| format!("couldn't find entry for '{desc}'"))?; for history in decrypted.history { println!("{}: {}", history.last_used_date, history.password); } Ok(()) } pub fn lock() -> anyhow::Result<()> { ensure_agent()?; crate::actions::lock()?; Ok(()) } pub fn purge() -> anyhow::Result<()> { stop_agent()?; remove_db()?; Ok(()) } pub fn stop_agent() -> anyhow::Result<()> { crate::actions::quit()?; Ok(()) } fn ensure_agent() -> anyhow::Result<()> { check_config()?; ensure_agent_once()?; let client_version = rbw::protocol::version(); let agent_version = version_or_quit()?; if agent_version != client_version { log::debug!( "client protocol version is {} but agent protocol version is {}", client_version, agent_version ); crate::actions::quit()?; ensure_agent_once()?; let agent_version = version_or_quit()?; if agent_version != client_version { crate::actions::quit()?; return Err(anyhow::anyhow!( "incompatible protocol versions: client ({}), agent ({})", client_version, agent_version )); } } Ok(()) } fn ensure_agent_once() -> anyhow::Result<()> { let agent_path = std::env::var("RBW_AGENT"); let agent_path = agent_path .as_ref() .map(std::string::String::as_str) .unwrap_or("rbw-agent"); let status = std::process::Command::new(agent_path) .status() .context("failed to run rbw-agent")?; if !status.success() { if let Some(code) = status.code() { if code != 23 { return Err(anyhow::anyhow!( "failed to run rbw-agent: {}", status )); } } } Ok(()) } fn check_config() -> anyhow::Result<()> { rbw::config::Config::validate().map_err(|e| { log::error!("{}", MISSING_CONFIG_HELP); anyhow::Error::new(e) }) } fn version_or_quit() -> anyhow::Result { crate::actions::version().map_err(|e| { let _ = crate::actions::quit(); e }) } fn find_entry( db: &rbw::db::Db, needle: &Needle, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { if let Needle::Uuid(uuid) = needle { for cipher in &db.entries { if uuid::Uuid::parse_str(&cipher.id) == Ok(*uuid) { return Ok((cipher.clone(), decrypt_cipher(cipher)?)); } } Err(anyhow::anyhow!("no entry found")) } else { let ciphers: Vec<(rbw::db::Entry, DecryptedCipher)> = db .entries .iter() .cloned() .map(|entry| { decrypt_cipher(&entry).map(|decrypted| (entry, decrypted)) }) .collect::>()?; find_entry_raw(&ciphers, needle, username, folder) } } fn find_entry_raw( entries: &[(rbw::db::Entry, DecryptedCipher)], needle: &Needle, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { let mut matches: Vec<(rbw::db::Entry, DecryptedCipher)> = entries .iter() .filter(|&(_, decrypted_cipher)| { decrypted_cipher.exact_match(needle, username, folder, true) }) .cloned() .collect(); if matches.len() == 1 { return Ok(matches[0].clone()); } if folder.is_none() { matches = entries .iter() .filter(|&(_, decrypted_cipher)| { decrypted_cipher.exact_match(needle, username, folder, false) }) .cloned() .collect(); if matches.len() == 1 { return Ok(matches[0].clone()); } } if let Needle::Name(name) = needle { matches = entries .iter() .filter(|&(_, decrypted_cipher)| { decrypted_cipher.partial_match(name, username, folder, true) }) .cloned() .collect(); if matches.len() == 1 { return Ok(matches[0].clone()); } if folder.is_none() { matches = entries .iter() .filter(|&(_, decrypted_cipher)| { decrypted_cipher .partial_match(name, username, folder, false) }) .cloned() .collect(); if matches.len() == 1 { return Ok(matches[0].clone()); } } } if matches.is_empty() { Err(anyhow::anyhow!("no entry found")) } else { let entries: Vec = matches .iter() .map(|(_, decrypted)| decrypted.display_name()) .collect(); let entries = entries.join(", "); Err(anyhow::anyhow!("multiple entries found: {}", entries)) } } fn decrypt_field( name: &str, field: Option<&str>, org_id: Option<&str>, ) -> Option { let field = field .as_ref() .map(|field| crate::actions::decrypt(field, org_id)) .transpose(); match field { Ok(field) => field, Err(e) => { log::warn!("failed to decrypt {}: {}", name, e); None } } } fn decrypt_cipher(entry: &rbw::db::Entry) -> anyhow::Result { // folder name should always be decrypted with the local key because // folders are local to a specific user's vault, not the organization let folder = entry .folder .as_ref() .map(|folder| crate::actions::decrypt(folder, None)) .transpose(); let folder = match folder { Ok(folder) => folder, Err(e) => { log::warn!("failed to decrypt folder name: {}", e); None } }; let fields = entry .fields .iter() .map(|field| { Ok(DecryptedField { name: field .name .as_ref() .map(|name| { crate::actions::decrypt(name, entry.org_id.as_deref()) }) .transpose()?, value: field .value .as_ref() .map(|value| { crate::actions::decrypt( value, entry.org_id.as_deref(), ) }) .transpose()?, }) }) .collect::>()?; let notes = entry .notes .as_ref() .map(|notes| crate::actions::decrypt(notes, entry.org_id.as_deref())) .transpose(); let notes = match notes { Ok(notes) => notes, Err(e) => { log::warn!("failed to decrypt notes: {}", e); None } }; let history = entry .history .iter() .map(|history_entry| { Ok(DecryptedHistoryEntry { last_used_date: history_entry.last_used_date.clone(), password: crate::actions::decrypt( &history_entry.password, entry.org_id.as_deref(), )?, }) }) .collect::>()?; let data = match &entry.data { rbw::db::EntryData::Login { username, password, totp, uris, } => DecryptedData::Login { username: decrypt_field( "username", username.as_deref(), entry.org_id.as_deref(), ), password: decrypt_field( "password", password.as_deref(), entry.org_id.as_deref(), ), totp: decrypt_field( "totp", totp.as_deref(), entry.org_id.as_deref(), ), uris: uris .iter() .map(|s| { decrypt_field( "uri", Some(&s.uri), entry.org_id.as_deref(), ) .map(|uri| DecryptedUri { uri, match_type: s.match_type, }) }) .collect(), }, rbw::db::EntryData::Card { cardholder_name, number, brand, exp_month, exp_year, code, } => DecryptedData::Card { cardholder_name: decrypt_field( "cardholder_name", cardholder_name.as_deref(), entry.org_id.as_deref(), ), number: decrypt_field( "number", number.as_deref(), entry.org_id.as_deref(), ), brand: decrypt_field( "brand", brand.as_deref(), entry.org_id.as_deref(), ), exp_month: decrypt_field( "exp_month", exp_month.as_deref(), entry.org_id.as_deref(), ), exp_year: decrypt_field( "exp_year", exp_year.as_deref(), entry.org_id.as_deref(), ), code: decrypt_field( "code", code.as_deref(), entry.org_id.as_deref(), ), }, rbw::db::EntryData::Identity { title, first_name, middle_name, last_name, address1, address2, address3, city, state, postal_code, country, phone, email, ssn, license_number, passport_number, username, } => DecryptedData::Identity { title: decrypt_field( "title", title.as_deref(), entry.org_id.as_deref(), ), first_name: decrypt_field( "first_name", first_name.as_deref(), entry.org_id.as_deref(), ), middle_name: decrypt_field( "middle_name", middle_name.as_deref(), entry.org_id.as_deref(), ), last_name: decrypt_field( "last_name", last_name.as_deref(), entry.org_id.as_deref(), ), address1: decrypt_field( "address1", address1.as_deref(), entry.org_id.as_deref(), ), address2: decrypt_field( "address2", address2.as_deref(), entry.org_id.as_deref(), ), address3: decrypt_field( "address3", address3.as_deref(), entry.org_id.as_deref(), ), city: decrypt_field( "city", city.as_deref(), entry.org_id.as_deref(), ), state: decrypt_field( "state", state.as_deref(), entry.org_id.as_deref(), ), postal_code: decrypt_field( "postal_code", postal_code.as_deref(), entry.org_id.as_deref(), ), country: decrypt_field( "country", country.as_deref(), entry.org_id.as_deref(), ), phone: decrypt_field( "phone", phone.as_deref(), entry.org_id.as_deref(), ), email: decrypt_field( "email", email.as_deref(), entry.org_id.as_deref(), ), ssn: decrypt_field( "ssn", ssn.as_deref(), entry.org_id.as_deref(), ), license_number: decrypt_field( "license_number", license_number.as_deref(), entry.org_id.as_deref(), ), passport_number: decrypt_field( "passport_number", passport_number.as_deref(), entry.org_id.as_deref(), ), username: decrypt_field( "username", username.as_deref(), entry.org_id.as_deref(), ), }, rbw::db::EntryData::SecureNote {} => DecryptedData::SecureNote {}, }; Ok(DecryptedCipher { id: entry.id.clone(), folder, name: crate::actions::decrypt(&entry.name, entry.org_id.as_deref())?, data, fields, notes, history, }) } fn parse_editor(contents: &str) -> (Option, Option) { let mut lines = contents.lines(); let password = lines.next().map(std::string::ToString::to_string); let mut notes: String = lines .skip_while(|line| line.is_empty()) .filter(|line| !line.starts_with('#')) .fold(String::new(), |mut notes, line| { notes.push_str(line); notes.push('\n'); notes }); while notes.ends_with('\n') { notes.pop(); } let notes = if notes.is_empty() { None } else { Some(notes) }; (password, notes) } fn load_db() -> anyhow::Result { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( || Err(anyhow::anyhow!("failed to find email address in config")), |email| { rbw::db::Db::load(&config.server_name(), email) .map_err(anyhow::Error::new) }, ) } fn save_db(db: &rbw::db::Db) -> anyhow::Result<()> { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( || Err(anyhow::anyhow!("failed to find email address in config")), |email| { db.save(&config.server_name(), email) .map_err(anyhow::Error::new) }, ) } fn remove_db() -> anyhow::Result<()> { let config = rbw::config::Config::load()?; config.email.as_ref().map_or_else( || Err(anyhow::anyhow!("failed to find email address in config")), |email| { rbw::db::Db::remove(&config.server_name(), email) .map_err(anyhow::Error::new) }, ) } fn parse_totp_secret(secret: &str) -> anyhow::Result> { let secret_str = if let Ok(u) = url::Url::parse(secret) { if u.scheme() != "otpauth" { return Err(anyhow::anyhow!( "totp secret url must have otpauth scheme" )); } if u.host_str() != Some("totp") { return Err(anyhow::anyhow!( "totp secret url must have totp host" )); } let query: std::collections::HashMap<_, _> = u.query_pairs().collect(); query .get("secret") .ok_or_else(|| { anyhow::anyhow!("totp secret url must have secret") })? .to_string() } else { secret.to_string() }; base32::decode( base32::Alphabet::RFC4648 { padding: false }, &secret_str.replace(' ', ""), ) .ok_or_else(|| anyhow::anyhow!("totp secret was not valid base32")) } fn generate_totp(secret: &str) -> anyhow::Result { let key = parse_totp_secret(secret)?; Ok(totp_lite::totp_custom::( totp_lite::DEFAULT_STEP, 6, &key, std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH)? .as_secs(), )) } fn display_field(name: &str, field: Option<&str>, clipboard: bool) -> bool { field.map_or_else( || false, |field| val_display_or_store(clipboard, &format!("{name}: {field}")), ) } #[cfg(test)] mod test { use super::*; #[test] fn test_find_entry() { let entries = &[ make_entry("github", Some("foo"), None, &[]), make_entry("gitlab", Some("foo"), None, &[]), make_entry("gitlab", Some("bar"), None, &[]), make_entry("gitter", Some("baz"), None, &[]), make_entry("git", Some("foo"), None, &[]), make_entry("bitwarden", None, None, &[]), make_entry("github", Some("foo"), Some("websites"), &[]), make_entry("github", Some("foo"), Some("ssh"), &[]), make_entry("github", Some("root"), Some("ssh"), &[]), ]; assert!( one_match(entries, "github", Some("foo"), None, 0), "foo@github" ); assert!(one_match(entries, "github", None, None, 0), "github"); assert!( one_match(entries, "gitlab", Some("foo"), None, 1), "foo@gitlab" ); assert!(one_match(entries, "git", Some("bar"), None, 2), "bar@git"); assert!( one_match(entries, "gitter", Some("ba"), None, 3), "ba@gitter" ); assert!(one_match(entries, "git", Some("foo"), None, 4), "foo@git"); assert!(one_match(entries, "git", None, None, 4), "git"); assert!(one_match(entries, "bitwarden", None, None, 5), "bitwarden"); assert!( one_match(entries, "github", Some("foo"), Some("websites"), 6), "websites/foo@github" ); assert!( one_match(entries, "github", Some("foo"), Some("ssh"), 7), "ssh/foo@github" ); assert!( one_match(entries, "github", Some("root"), None, 8), "ssh/root@github" ); assert!( no_matches(entries, "gitlab", Some("baz"), None), "baz@gitlab" ); assert!( no_matches(entries, "bitbucket", Some("foo"), None), "foo@bitbucket" ); assert!( no_matches(entries, "github", Some("foo"), Some("bar")), "bar/foo@github" ); assert!( no_matches(entries, "gitlab", Some("foo"), Some("bar")), "bar/foo@gitlab" ); assert!(many_matches(entries, "gitlab", None, None), "gitlab"); assert!(many_matches(entries, "gi", Some("foo"), None), "foo@gi"); assert!(many_matches(entries, "git", Some("ba"), None), "ba@git"); assert!( many_matches(entries, "github", Some("foo"), Some("s")), "s/foo@github" ); } #[test] fn test_find_by_uuid() { let entries = &[ make_entry("github", Some("foo"), None, &[]), make_entry("gitlab", Some("foo"), None, &[]), make_entry("gitlab", Some("bar"), None, &[]), ]; assert!( one_match(entries, &entries[0].0.id, None, None, 0), "foo@github" ); assert!( one_match(entries, &entries[1].0.id, None, None, 1), "foo@gitlab" ); assert!( one_match(entries, &entries[2].0.id, None, None, 2), "bar@gitlab" ); assert!( one_match( entries, &entries[0].0.id.to_uppercase(), None, None, 0 ), "foo@github" ); assert!( one_match( entries, &entries[0].0.id.to_lowercase(), None, None, 0 ), "foo@github" ); } #[test] fn test_find_by_url_default() { let entries = &[ make_entry("one", None, None, &[("https://one.com/", None)]), make_entry("two", None, None, &[("https://two.com/login", None)]), make_entry( "three", None, None, &[("https://login.three.com/", None)], ), make_entry("four", None, None, &[("four.com", None)]), make_entry( "five", None, None, &[("https://five.com:8080/", None)], ), make_entry("six", None, None, &[("six.com:8080", None)]), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( one_match(entries, "https://login.one.com/", None, None, 0), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!(one_match(entries, "https://two.com/", None, None, 1), "two"); assert!( one_match(entries, "https://two.com/other-page", None, None, 1), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); assert!( one_match(entries, "https://four.com/", None, None, 3), "four" ); assert!( one_match(entries, "https://five.com:8080/", None, None, 4), "five" ); assert!(no_matches(entries, "https://five.com/", None, None), "five"); assert!( one_match(entries, "https://six.com:8080/", None, None, 5), "six" ); assert!(no_matches(entries, "https://six.com/", None, None), "six"); } #[test] fn test_find_by_url_domain() { let entries = &[ make_entry( "one", None, None, &[("https://one.com/", Some(rbw::api::UriMatchType::Domain))], ), make_entry( "two", None, None, &[( "https://two.com/login", Some(rbw::api::UriMatchType::Domain), )], ), make_entry( "three", None, None, &[( "https://login.three.com/", Some(rbw::api::UriMatchType::Domain), )], ), make_entry( "four", None, None, &[("four.com", Some(rbw::api::UriMatchType::Domain))], ), make_entry( "five", None, None, &[( "https://five.com:8080/", Some(rbw::api::UriMatchType::Domain), )], ), make_entry( "six", None, None, &[("six.com:8080", Some(rbw::api::UriMatchType::Domain))], ), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( one_match(entries, "https://login.one.com/", None, None, 0), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!(one_match(entries, "https://two.com/", None, None, 1), "two"); assert!( one_match(entries, "https://two.com/other-page", None, None, 1), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); assert!( one_match(entries, "https://four.com/", None, None, 3), "four" ); assert!( one_match(entries, "https://five.com:8080/", None, None, 4), "five" ); assert!(no_matches(entries, "https://five.com/", None, None), "five"); assert!( one_match(entries, "https://six.com:8080/", None, None, 5), "six" ); assert!(no_matches(entries, "https://six.com/", None, None), "six"); } #[test] fn test_find_by_url_host() { let entries = &[ make_entry( "one", None, None, &[("https://one.com/", Some(rbw::api::UriMatchType::Host))], ), make_entry( "two", None, None, &[( "https://two.com/login", Some(rbw::api::UriMatchType::Host), )], ), make_entry( "three", None, None, &[( "https://login.three.com/", Some(rbw::api::UriMatchType::Host), )], ), make_entry( "four", None, None, &[("four.com", Some(rbw::api::UriMatchType::Host))], ), make_entry( "five", None, None, &[( "https://five.com:8080/", Some(rbw::api::UriMatchType::Host), )], ), make_entry( "six", None, None, &[("six.com:8080", Some(rbw::api::UriMatchType::Host))], ), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( no_matches(entries, "https://login.one.com/", None, None), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!(one_match(entries, "https://two.com/", None, None, 1), "two"); assert!( one_match(entries, "https://two.com/other-page", None, None, 1), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); assert!( one_match(entries, "https://four.com/", None, None, 3), "four" ); assert!( one_match(entries, "https://five.com:8080/", None, None, 4), "five" ); assert!(no_matches(entries, "https://five.com/", None, None), "five"); assert!( one_match(entries, "https://six.com:8080/", None, None, 5), "six" ); assert!(no_matches(entries, "https://six.com/", None, None), "six"); } #[test] fn test_find_by_url_starts_with() { let entries = &[ make_entry( "one", None, None, &[( "https://one.com/", Some(rbw::api::UriMatchType::StartsWith), )], ), make_entry( "two", None, None, &[( "https://two.com/login", Some(rbw::api::UriMatchType::StartsWith), )], ), make_entry( "three", None, None, &[( "https://login.three.com/", Some(rbw::api::UriMatchType::StartsWith), )], ), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( no_matches(entries, "https://login.one.com/", None, None), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!( one_match(entries, "https://two.com/login", None, None, 1), "two" ); assert!( one_match(entries, "https://two.com/login/sso", None, None, 1), "two" ); assert!(no_matches(entries, "https://two.com/", None, None), "two"); assert!( no_matches(entries, "https://two.com/other-page", None, None), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); } #[test] fn test_find_by_url_exact() { let entries = &[ make_entry( "one", None, None, &[("https://one.com/", Some(rbw::api::UriMatchType::Exact))], ), make_entry( "two", None, None, &[( "https://two.com/login", Some(rbw::api::UriMatchType::Exact), )], ), make_entry( "three", None, None, &[( "https://login.three.com/", Some(rbw::api::UriMatchType::Exact), )], ), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( no_matches(entries, "https://login.one.com/", None, None), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!( one_match(entries, "https://two.com/login", None, None, 1), "two" ); assert!( no_matches(entries, "https://two.com/login/sso", None, None), "two" ); assert!(no_matches(entries, "https://two.com/", None, None), "two"); assert!( no_matches(entries, "https://two.com/other-page", None, None), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); } #[test] fn test_find_by_url_regex() { let entries = &[ make_entry( "one", None, None, &[( r"^https://one\.com/$", Some(rbw::api::UriMatchType::RegularExpression), )], ), make_entry( "two", None, None, &[( r"^https://two\.com/(login|start)", Some(rbw::api::UriMatchType::RegularExpression), )], ), make_entry( "three", None, None, &[( r"^https://(login\.)?three\.com/$", Some(rbw::api::UriMatchType::RegularExpression), )], ), ]; assert!(one_match(entries, "https://one.com/", None, None, 0), "one"); assert!( no_matches(entries, "https://login.one.com/", None, None), "one" ); assert!( one_match(entries, "https://one.com:443/", None, None, 0), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!( one_match(entries, "https://two.com/login", None, None, 1), "two" ); assert!( one_match(entries, "https://two.com/start", None, None, 1), "two" ); assert!( one_match(entries, "https://two.com/login/sso", None, None, 1), "two" ); assert!(no_matches(entries, "https://two.com/", None, None), "two"); assert!( no_matches(entries, "https://two.com/other-page", None, None), "two" ); assert!( one_match(entries, "https://login.three.com/", None, None, 2), "three" ); assert!( one_match(entries, "https://three.com/", None, None, 2), "three" ); assert!( no_matches(entries, "https://www.three.com/", None, None), "three" ); } #[test] fn test_find_by_url_never() { let entries = &[ make_entry( "one", None, None, &[("https://one.com/", Some(rbw::api::UriMatchType::Never))], ), make_entry( "two", None, None, &[( "https://two.com/login", Some(rbw::api::UriMatchType::Never), )], ), make_entry( "three", None, None, &[( "https://login.three.com/", Some(rbw::api::UriMatchType::Never), )], ), make_entry( "four", None, None, &[("four.com", Some(rbw::api::UriMatchType::Never))], ), make_entry( "five", None, None, &[( "https://five.com:8080/", Some(rbw::api::UriMatchType::Never), )], ), make_entry( "six", None, None, &[("six.com:8080", Some(rbw::api::UriMatchType::Never))], ), ]; assert!(no_matches(entries, "https://one.com/", None, None), "one"); assert!( no_matches(entries, "https://login.one.com/", None, None), "one" ); assert!( no_matches(entries, "https://one.com:443/", None, None), "one" ); assert!(no_matches(entries, "one.com", None, None), "one"); assert!(no_matches(entries, "https", None, None), "one"); assert!(no_matches(entries, "com", None, None), "one"); assert!(no_matches(entries, "https://com/", None, None), "one"); assert!(no_matches(entries, "https://two.com/", None, None), "two"); assert!( no_matches(entries, "https://two.com/other-page", None, None), "two" ); assert!( no_matches(entries, "https://login.three.com/", None, None), "three" ); assert!( no_matches(entries, "https://three.com/", None, None), "three" ); assert!(no_matches(entries, "https://four.com/", None, None), "four"); assert!( no_matches(entries, "https://five.com:8080/", None, None), "five" ); assert!(no_matches(entries, "https://five.com/", None, None), "five"); assert!( no_matches(entries, "https://six.com:8080/", None, None), "six" ); assert!(no_matches(entries, "https://six.com/", None, None), "six"); } #[track_caller] fn one_match( entries: &[(rbw::db::Entry, DecryptedCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, idx: usize, ) -> bool { entries_eq( &find_entry_raw( entries, &parse_needle(needle).unwrap(), username, folder, ) .unwrap(), &entries[idx], ) } #[track_caller] fn no_matches( entries: &[(rbw::db::Entry, DecryptedCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, ) -> bool { let res = find_entry_raw( entries, &parse_needle(needle).unwrap(), username, folder, ); if let Err(e) = res { format!("{e}").contains("no entry found") } else { false } } #[track_caller] fn many_matches( entries: &[(rbw::db::Entry, DecryptedCipher)], needle: &str, username: Option<&str>, folder: Option<&str>, ) -> bool { let res = find_entry_raw( entries, &parse_needle(needle).unwrap(), username, folder, ); if let Err(e) = res { format!("{e}").contains("multiple entries found") } else { false } } #[track_caller] fn entries_eq( a: &(rbw::db::Entry, DecryptedCipher), b: &(rbw::db::Entry, DecryptedCipher), ) -> bool { a.0 == b.0 && a.1 == b.1 } fn make_entry( name: &str, username: Option<&str>, folder: Option<&str>, uris: &[(&str, Option)], ) -> (rbw::db::Entry, DecryptedCipher) { let id = uuid::Uuid::new_v4(); ( rbw::db::Entry { id: id.to_string(), org_id: None, folder: folder.map(|_| "encrypted folder name".to_string()), folder_id: None, name: "this is the encrypted name".to_string(), data: rbw::db::EntryData::Login { username: username.map(|_| { "this is the encrypted username".to_string() }), password: None, uris: uris .iter() .map(|(_, match_type)| rbw::db::Uri { uri: "this is the encrypted uri".to_string(), match_type: *match_type, }) .collect(), totp: None, }, fields: vec![], notes: None, history: vec![], }, DecryptedCipher { id: id.to_string(), folder: folder.map(std::string::ToString::to_string), name: name.to_string(), data: DecryptedData::Login { username: username.map(std::string::ToString::to_string), password: None, totp: None, uris: Some( uris.iter() .map(|(uri, match_type)| DecryptedUri { uri: (*uri).to_string(), match_type: *match_type, }) .collect(), ), }, fields: vec![], notes: None, history: vec![], }, ) } }