aboutsummaryrefslogtreecommitdiffstats
path: root/src/bin/rbw/commands.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/rbw/commands.rs')
-rw-r--r--src/bin/rbw/commands.rs1582
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(&notes, 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,