diff options
Diffstat (limited to 'src/bin/rbw/commands.rs')
-rw-r--r-- | src/bin/rbw/commands.rs | 1582 |
1 files changed, 1353 insertions, 229 deletions
diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs index 9efd966..3329f76 100644 --- a/src/bin/rbw/commands.rs +++ b/src/bin/rbw/commands.rs @@ -1,4 +1,9 @@ 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 \ @@ -11,6 +16,36 @@ const MISSING_CONFIG_HELP: &str = rbw config set identity_url <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<Needle, std::convert::Infallible> { + 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, @@ -23,25 +58,25 @@ struct DecryptedCipher { } impl DecryptedCipher { - fn display_short(&self, desc: &str) -> bool { + fn display_short(&self, desc: &str, clipboard: bool) -> bool { match &self.data { DecryptedData::Login { password, .. } => { - if let Some(password) = password { - println!("{}", password); - true - } else { - eprintln!("entry for '{}' had no password", desc); - false - } + password.as_ref().map_or_else( + || { + eprintln!("entry for '{desc}' had no password"); + false + }, + |password| val_display_or_store(clipboard, password), + ) } DecryptedData::Card { number, .. } => { - if let Some(number) = number { - println!("{}", number); - true - } else { - eprintln!("entry for '{}' had no card number", desc); - false - } + 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, @@ -54,30 +89,272 @@ impl DecryptedCipher { [title, first_name, middle_name, last_name] .iter() .copied() - .cloned() .flatten() + .cloned() .collect(); if names.is_empty() { - eprintln!("entry for '{}' had no name", desc); + eprintln!("entry for '{desc}' had no name"); false } else { - println!("{}", names.join(" ")); - true + val_display_or_store(clipboard, &names.join(" ")) } } - DecryptedData::SecureNote {} => { - if let Some(notes) = &self.notes { - println!("{}", notes); - true - } else { - eprintln!("entry for '{}' had no notes", desc); + 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) { + fn display_long(&self, desc: &str, clipboard: bool) { match &self.data { DecryptedData::Login { username, @@ -85,29 +362,31 @@ impl DecryptedCipher { uris, .. } => { - let mut displayed = self.display_short(desc); + let mut displayed = self.display_short(desc, clipboard); displayed |= - self.display_field("Username", username.as_deref()); + display_field("Username", username.as_deref(), clipboard); displayed |= - self.display_field("TOTP Secret", totp.as_deref()); + display_field("TOTP Secret", totp.as_deref(), clipboard); if let Some(uris) = uris { for uri in uris { displayed |= - self.display_field("URI", Some(&uri.uri)); + display_field("URI", Some(&uri.uri), clipboard); let match_type = - uri.match_type.map(|ty| format!("{}", ty)); - displayed |= self.display_field( + uri.match_type.map(|ty| format!("{ty}")); + displayed |= display_field( "Match type", match_type.as_deref(), + clipboard, ); } } for field in &self.fields { - displayed |= self.display_field( + displayed |= display_field( field.name.as_deref().unwrap_or("(null)"), Some(field.value.as_deref().unwrap_or("")), + clipboard, ); } @@ -115,7 +394,7 @@ impl DecryptedCipher { if displayed { println!(); } - println!("{}", notes); + println!("{notes}"); } } DecryptedData::Card { @@ -126,24 +405,28 @@ impl DecryptedCipher { code, .. } => { - let mut displayed = self.display_short(desc); + 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); + println!("Expiration: {exp_month}/{exp_year}"); displayed = true; } - displayed |= self.display_field("CVV", code.as_deref()); + displayed |= display_field("CVV", code.as_deref(), clipboard); + displayed |= display_field( + "Name", + cardholder_name.as_deref(), + clipboard, + ); displayed |= - self.display_field("Name", cardholder_name.as_deref()); - displayed |= self.display_field("Brand", brand.as_deref()); + display_field("Brand", brand.as_deref(), clipboard); if let Some(notes) = &self.notes { if displayed { println!(); } - println!("{}", notes); + println!("{notes}"); } } DecryptedData::Identity { @@ -162,74 +445,110 @@ impl DecryptedCipher { username, .. } => { - let mut displayed = self.display_short(desc); + let mut displayed = self.display_short(desc, clipboard); displayed |= - self.display_field("Address", address1.as_deref()); + display_field("Address", address1.as_deref(), clipboard); displayed |= - self.display_field("Address", address2.as_deref()); + display_field("Address", address2.as_deref(), clipboard); displayed |= - self.display_field("Address", address3.as_deref()); - displayed |= self.display_field("City", city.as_deref()); - displayed |= self.display_field("State", state.as_deref()); + display_field("Address", address3.as_deref(), clipboard); displayed |= - self.display_field("Postcode", postal_code.as_deref()); + display_field("City", city.as_deref(), clipboard); displayed |= - self.display_field("Country", country.as_deref()); - displayed |= self.display_field("Phone", phone.as_deref()); - displayed |= self.display_field("Email", email.as_deref()); - displayed |= self.display_field("SSN", ssn.as_deref()); + display_field("State", state.as_deref(), clipboard); + displayed |= display_field( + "Postcode", + postal_code.as_deref(), + clipboard, + ); displayed |= - self.display_field("License", license_number.as_deref()); - displayed |= self - .display_field("Passport", passport_number.as_deref()); + display_field("Country", country.as_deref(), clipboard); displayed |= - self.display_field("Username", username.as_deref()); + 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); + println!("{notes}"); } } DecryptedData::SecureNote {} => { - self.display_short(desc); + self.display_short(desc, clipboard); } } } - fn display_field(&self, name: &str, field: Option<&str>) -> bool { - if let Some(field) = field { - println!("{}: {}", name, field); - true - } else { - false - } - } - fn display_name(&self) -> String { match &self.data { DecryptedData::Login { username, .. } => { - if let Some(username) = username { - format!("{}@{}", username, self.name) - } else { - self.name.clone() - } + 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, - name: &str, + needle: &Needle, username: Option<&str>, folder: Option<&str>, try_match_folder: bool, ) -> bool { - if name != self.name { - return false; + 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 { @@ -312,7 +631,23 @@ impl DecryptedCipher { } } -#[derive(Debug, Clone)] +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 { @@ -351,27 +686,97 @@ enum DecryptedData { SecureNote, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedField { name: Option<String>, value: Option<String>, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedHistoryEntry { last_used_date: String, password: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] #[cfg_attr(test, derive(Eq, PartialEq))] struct DecryptedUri { uri: String, match_type: Option<rbw::api::UriMatchType>, } +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<String> { + let host = url.host_str()?; + Some( + url.port().map_or_else( + || host.to_string(), + |port| format!("{host}:{port}"), + ), + ) +} + +fn domain_port(url: &Url) -> Option<String> { + let domain = url.domain()?; + Some(url.port().map_or_else( + || domain.to_string(), + |port| format!("{domain}:{port}"), + )) +} + enum ListField { Name, Id, @@ -393,11 +798,16 @@ impl std::convert::TryFrom<&String> for ListField { } } -const HELP: &str = r#" +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()?; @@ -415,6 +825,13 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { "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() @@ -425,6 +842,12 @@ pub fn config_set(key: &str, value: &str) -> anyhow::Result<()> { 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)), } @@ -447,8 +870,10 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { "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() + config.lock_timeout = rbw::config::default_lock_timeout(); } "pinentry" => config.pinentry = rbw::config::default_pinentry(), _ => return Err(anyhow::anyhow!("invalid config key: {}", key)), @@ -465,6 +890,13 @@ pub fn config_unset(key: &str) -> anyhow::Result<()> { 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()?; @@ -514,8 +946,7 @@ pub fn list(fields: &[String]) -> anyhow::Result<()> { let mut ciphers: Vec<DecryptedCipher> = db .entries .iter() - .cloned() - .map(|entry| decrypt_cipher(&entry)) + .map(decrypt_cipher) .collect::<anyhow::Result<_>>()?; ciphers.sort_unstable_by(|a, b| a.name.cmp(&b.name)); @@ -526,30 +957,40 @@ pub fn list(fields: &[String]) -> anyhow::Result<()> { ListField::Name => cipher.name.clone(), ListField::Id => cipher.id.clone(), ListField::User => match &cipher.data { - DecryptedData::Login { username, .. } => username - .as_ref() - .map(std::string::ToString::to_string) - .unwrap_or_else(|| "".to_string()), - _ => "".to_string(), + 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(std::string::ToString::to_string) - .unwrap_or_else(|| "".to_string()), + ListField::Folder => cipher.folder.as_ref().map_or_else( + String::new, + std::string::ToString::to_string, + ), }) .collect(); - println!("{}", values.join("\t")); + + // 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( - name: &str, + needle: &Needle, user: Option<&str>, folder: Option<&str>, + field: Option<&str>, full: bool, + raw: bool, + clipboard: bool, ) -> anyhow::Result<()> { unlock()?; @@ -557,17 +998,20 @@ pub fn get( let desc = format!( "{}{}", - user.map(|s| format!("{}@", s)) - .unwrap_or_else(|| "".to_string()), - name + user.map_or_else(String::new, |s| format!("{s}@")), + needle ); - let (_, decrypted) = find_entry(&db, name, user, folder) - .with_context(|| format!("couldn't find entry for '{}'", desc))?; - if full { - decrypted.display_long(&desc); + 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); + decrypted.display_short(&desc, clipboard); } Ok(()) @@ -577,6 +1021,7 @@ pub fn code( name: &str, user: Option<&str>, folder: Option<&str>, + clipboard: bool, ) -> anyhow::Result<()> { unlock()?; @@ -584,17 +1029,17 @@ pub fn code( let desc = format!( "{}{}", - user.map(|s| format!("{}@", s)) - .unwrap_or_else(|| "".to_string()), + user.map_or_else(String::new, |s| format!("{s}@")), name ); - let (_, decrypted) = find_entry(&db, name, user, folder) - .with_context(|| format!("couldn't find entry for '{}'", desc))?; + 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 { - println!("{}", generate_totp(&totp)?) + val_display_or_store(clipboard, &generate_totp(&totp)?); } else { return Err(anyhow::anyhow!( "entry does not contain a totp secret" @@ -610,7 +1055,7 @@ pub fn code( pub fn add( name: &str, username: Option<&str>, - uris: Vec<(String, Option<rbw::api::UriMatchType>)>, + uris: &[(String, Option<rbw::api::UriMatchType>)], folder: Option<&str>, ) -> anyhow::Result<()> { unlock()?; @@ -627,7 +1072,7 @@ pub fn add( .map(|username| crate::actions::encrypt(username, None)) .transpose()?; - let contents = rbw::edit::edit("", HELP)?; + let contents = rbw::edit::edit("", HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password @@ -707,13 +1152,13 @@ pub fn add( pub fn generate( name: Option<&str>, username: Option<&str>, - uris: Vec<(String, Option<rbw::api::UriMatchType>)>, + uris: &[(String, Option<rbw::api::UriMatchType>)], folder: Option<&str>, len: usize, ty: rbw::pwgen::Type, ) -> anyhow::Result<()> { let password = rbw::pwgen::pwgen(ty, len); - println!("{}", password); + println!("{password}"); if let Some(name) = name { unlock()?; @@ -813,24 +1258,23 @@ pub fn edit( let desc = format!( "{}{}", - username - .map(|s| format!("{}@", s)) - .unwrap_or_else(|| "".to_string()), + username.map_or_else(String::new, |s| format!("{s}@")), name ); - let (entry, decrypted) = find_entry(&db, name, username, folder) - .with_context(|| format!("couldn't find entry for '{}'", desc))?; + let (entry, decrypted) = + find_entry(&db, &Needle::Name(name.to_string()), username, folder) + .with_context(|| format!("couldn't find entry for '{desc}'"))?; - let (data, notes, history) = match &decrypted.data { + 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{}\n", notes)); + contents.push_str(&format!("\n{notes}\n")); } - let contents = rbw::edit::edit(&contents, HELP)?; + let contents = rbw::edit::edit(&contents, HELP_PW)?; let (password, notes) = parse_editor(&contents); let password = password @@ -847,16 +1291,15 @@ pub fn edit( }) .transpose()?; let mut history = entry.history.clone(); - let (entry_username, entry_password, entry_uris, entry_totp) = - match &entry.data { - rbw::db::EntryData::Login { - username, - password, - uris, - totp, - } => (username, password, uris, totp), - _ => unreachable!(), - }; + 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 { @@ -874,14 +1317,34 @@ pub fn edit( let data = rbw::db::EntryData::Login { username: entry_username.clone(), password, - uris: entry_uris.to_vec(), + uris: entry_uris.clone(), totp: entry_totp.clone(), }; - (data, notes, history) + (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 entries" + "modifications are only supported for login and note entries" )); } }; @@ -893,6 +1356,7 @@ pub fn edit( entry.org_id.as_deref(), &entry.name, &data, + &fields, notes.as_deref(), entry.folder_id.as_deref(), &history, @@ -918,14 +1382,13 @@ pub fn remove( let desc = format!( "{}{}", - username - .map(|s| format!("{}@", s)) - .unwrap_or_else(|| "".to_string()), + username.map_or_else(String::new, |s| format!("{s}@")), name ); - let (entry, _) = find_entry(&db, name, username, folder) - .with_context(|| format!("couldn't find entry for '{}'", desc))?; + 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)? @@ -950,14 +1413,13 @@ pub fn history( let desc = format!( "{}{}", - username - .map(|s| format!("{}@", s)) - .unwrap_or_else(|| "".to_string()), + username.map_or_else(String::new, |s| format!("{s}@")), name ); - let (_, decrypted) = find_entry(&db, name, username, folder) - .with_context(|| format!("couldn't find entry for '{}'", desc))?; + 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); } @@ -1017,7 +1479,7 @@ fn ensure_agent_once() -> anyhow::Result<()> { let agent_path = std::env::var("RBW_AGENT"); let agent_path = agent_path .as_ref() - .map(|s| s.as_str()) + .map(std::string::String::as_str) .unwrap_or("rbw-agent"); let status = std::process::Command::new(agent_path) .status() @@ -1052,45 +1514,42 @@ fn version_or_quit() -> anyhow::Result<u32> { fn find_entry( db: &rbw::db::Db, - name: &str, + needle: &Needle, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { - match uuid::Uuid::parse_str(name) { - Ok(_) => { - for cipher in &db.entries { - if name == cipher.id { - return Ok((cipher.clone(), decrypt_cipher(cipher)?)); - } + 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")) - } - Err(_) => { - let ciphers: Vec<(rbw::db::Entry, DecryptedCipher)> = db - .entries - .iter() - .cloned() - .map(|entry| { - decrypt_cipher(&entry).map(|decrypted| (entry, decrypted)) - }) - .collect::<anyhow::Result<_>>()?; - find_entry_raw(&ciphers, name, username, folder) } + 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::<anyhow::Result<_>>()?; + find_entry_raw(&ciphers, needle, username, folder) } } fn find_entry_raw( entries: &[(rbw::db::Entry, DecryptedCipher)], - name: &str, + needle: &Needle, username: Option<&str>, folder: Option<&str>, ) -> anyhow::Result<(rbw::db::Entry, DecryptedCipher)> { let mut matches: Vec<(rbw::db::Entry, DecryptedCipher)> = entries .iter() - .cloned() - .filter(|(_, decrypted_cipher)| { - decrypted_cipher.exact_match(name, username, folder, true) + .filter(|&(_, decrypted_cipher)| { + decrypted_cipher.exact_match(needle, username, folder, true) }) + .cloned() .collect(); if matches.len() == 1 { @@ -1100,10 +1559,10 @@ fn find_entry_raw( if folder.is_none() { matches = entries .iter() - .cloned() - .filter(|(_, decrypted_cipher)| { - decrypted_cipher.exact_match(name, username, folder, false) + .filter(|&(_, decrypted_cipher)| { + decrypted_cipher.exact_match(needle, username, folder, false) }) + .cloned() .collect(); if matches.len() == 1 { @@ -1111,29 +1570,32 @@ fn find_entry_raw( } } - matches = entries - .iter() - .cloned() - .filter(|(_, decrypted_cipher)| { - decrypted_cipher.partial_match(name, username, folder, true) - }) - .collect(); - - if matches.len() == 1 { - return Ok(matches[0].clone()); - } - - if folder.is_none() { + if let Needle::Name(name) = needle { matches = entries .iter() - .cloned() - .filter(|(_, decrypted_cipher)| { - decrypted_cipher.partial_match(name, username, folder, false) + .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() { @@ -1435,8 +1897,11 @@ fn parse_editor(contents: &str) -> (Option<String>, Option<String>) { let mut notes: String = lines .skip_while(|line| line.is_empty()) .filter(|line| !line.starts_with('#')) - .map(|line| format!("{}\n", line)) - .collect(); + .fold(String::new(), |mut notes, line| { + notes.push_str(line); + notes.push('\n'); + notes + }); while notes.ends_with('\n') { notes.pop(); } @@ -1447,32 +1912,35 @@ fn parse_editor(contents: &str) -> (Option<String>, Option<String>) { fn load_db() -> anyhow::Result<rbw::db::Db> { let config = rbw::config::Config::load()?; - if let Some(email) = &config.email { - rbw::db::Db::load(&config.server_name(), email) - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } + 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()?; - if let Some(email) = &config.email { - db.save(&config.server_name(), email) - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } + 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()?; - if let Some(email) = &config.email { - rbw::db::Db::remove(&config.server_name(), email) - .map_err(anyhow::Error::new) - } else { - Err(anyhow::anyhow!("failed to find email address in config")) - } + 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<Vec<u8>> { @@ -1500,7 +1968,7 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result<Vec<u8>> { }; base32::decode( base32::Alphabet::RFC4648 { padding: false }, - &secret_str.replace(" ", ""), + &secret_str.replace(' ', ""), ) .ok_or_else(|| anyhow::anyhow!("totp secret was not valid base32")) } @@ -1517,6 +1985,13 @@ fn generate_totp(secret: &str) -> anyhow::Result<String> { )) } +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::*; @@ -1524,15 +1999,15 @@ mod test { #[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")), + 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!( @@ -1591,47 +2066,681 @@ mod test { ); } + #[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)], - name: &str, + needle: &str, username: Option<&str>, folder: Option<&str>, idx: usize, ) -> bool { entries_eq( - &find_entry_raw(entries, name, username, folder).unwrap(), + &find_entry_raw( + entries, + &parse_needle(needle).unwrap(), + username, + folder, + ) + .unwrap(), &entries[idx], ) } + #[track_caller] fn no_matches( entries: &[(rbw::db::Entry, DecryptedCipher)], - name: &str, + needle: &str, username: Option<&str>, folder: Option<&str>, ) -> bool { - let res = find_entry_raw(entries, name, username, folder); + let res = find_entry_raw( + entries, + &parse_needle(needle).unwrap(), + username, + folder, + ); if let Err(e) = res { - format!("{}", e).contains("no entry found") + format!("{e}").contains("no entry found") } else { false } } + #[track_caller] fn many_matches( entries: &[(rbw::db::Entry, DecryptedCipher)], - name: &str, + needle: &str, username: Option<&str>, folder: Option<&str>, ) -> bool { - let res = find_entry_raw(entries, name, username, folder); + let res = find_entry_raw( + entries, + &parse_needle(needle).unwrap(), + username, + folder, + ); if let Err(e) = res { - format!("{}", e).contains("multiple entries found") + format!("{e}").contains("multiple entries found") } else { false } } + #[track_caller] fn entries_eq( a: &(rbw::db::Entry, DecryptedCipher), b: &(rbw::db::Entry, DecryptedCipher), @@ -1643,10 +2752,12 @@ mod test { name: &str, username: Option<&str>, folder: Option<&str>, + uris: &[(&str, Option<rbw::api::UriMatchType>)], ) -> (rbw::db::Entry, DecryptedCipher) { + let id = uuid::Uuid::new_v4(); ( rbw::db::Entry { - id: "irrelevant".to_string(), + id: id.to_string(), org_id: None, folder: folder.map(|_| "encrypted folder name".to_string()), folder_id: None, @@ -1656,7 +2767,13 @@ mod test { "this is the encrypted username".to_string() }), password: None, - uris: vec![], + 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![], @@ -1664,14 +2781,21 @@ mod test { history: vec![], }, DecryptedCipher { - id: "irrelevant".to_string(), + 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: None, + uris: Some( + uris.iter() + .map(|(uri, match_type)| DecryptedUri { + uri: (*uri).to_string(), + match_type: *match_type, + }) + .collect(), + ), }, fields: vec![], notes: None, |