aboutsummaryrefslogtreecommitdiffstats
path: root/src/bin
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin')
-rw-r--r--src/bin/rbw-agent/actions.rs331
-rw-r--r--src/bin/rbw-agent/agent.rs205
-rw-r--r--src/bin/rbw-agent/daemon.rs59
-rw-r--r--src/bin/rbw-agent/debugger.rs2
-rw-r--r--src/bin/rbw-agent/main.rs31
-rw-r--r--src/bin/rbw-agent/notifications.rs174
-rw-r--r--src/bin/rbw-agent/sock.rs5
-rw-r--r--src/bin/rbw-agent/timeout.rs66
-rw-r--r--src/bin/rbw/actions.rs48
-rw-r--r--src/bin/rbw/commands.rs1687
-rw-r--r--src/bin/rbw/main.rs230
11 files changed, 2233 insertions, 605 deletions
diff --git a/src/bin/rbw-agent/actions.rs b/src/bin/rbw-agent/actions.rs
index 1cc71c3..674442b 100644
--- a/src/bin/rbw-agent/actions.rs
+++ b/src/bin/rbw-agent/actions.rs
@@ -10,9 +10,7 @@ pub async fn register(
let url_str = config_base_url().await?;
let url = reqwest::Url::parse(&url_str)
.context("failed to parse base url")?;
- let host = if let Some(host) = url.host_str() {
- host
- } else {
+ let Some(host) = url.host_str() else {
return Err(anyhow::anyhow!(
"couldn't find host in rbw base url {}",
url_str
@@ -33,7 +31,7 @@ pub async fn register(
let client_id = rbw::pinentry::getpin(
&config_pinentry().await?,
"API key client__id",
- &format!("Log in to {}", host),
+ &format!("Log in to {host}"),
err.as_deref(),
tty,
false,
@@ -43,7 +41,7 @@ pub async fn register(
let client_secret = rbw::pinentry::getpin(
&config_pinentry().await?,
"API key client__secret",
- &format!("Log in to {}", host),
+ &format!("Log in to {host}"),
err.as_deref(),
tty,
false,
@@ -61,10 +59,9 @@ pub async fn register(
message,
})
.context("failed to log in to bitwarden instance");
- } else {
- err_msg = Some(message);
- continue;
}
+ err_msg = Some(message);
+ continue;
}
Err(e) => {
return Err(e)
@@ -81,7 +78,7 @@ pub async fn register(
pub async fn login(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
tty: Option<&str>,
) -> anyhow::Result<()> {
let db = load_db().await.unwrap_or_else(|_| rbw::db::Db::new());
@@ -90,9 +87,7 @@ pub async fn login(
let url_str = config_base_url().await?;
let url = reqwest::Url::parse(&url_str)
.context("failed to parse base url")?;
- let host = if let Some(host) = url.host_str() {
- host
- } else {
+ let Some(host) = url.host_str() else {
return Err(anyhow::anyhow!(
"couldn't find host in rbw base url {}",
url_str
@@ -102,7 +97,7 @@ pub async fn login(
let email = config_email().await?;
let mut err_msg = None;
- for i in 1_u8..=3 {
+ 'attempts: for i in 1_u8..=3 {
let err = if i > 1 {
// this unwrap is safe because we only ever continue the loop
// if we have set err_msg
@@ -113,7 +108,7 @@ pub async fn login(
let password = rbw::pinentry::getpin(
&config_pinentry().await?,
"Master Password",
- &format!("Log in to {}", host),
+ &format!("Log in to {host}"),
err.as_deref(),
tty,
true,
@@ -126,55 +121,70 @@ pub async fn login(
Ok((
access_token,
refresh_token,
+ kdf,
iterations,
+ memory,
+ parallelism,
protected_key,
)) => {
login_success(
- sock,
- state,
+ state.clone(),
access_token,
refresh_token,
+ kdf,
iterations,
+ memory,
+ parallelism,
protected_key,
password,
db,
email,
)
.await?;
- break;
+ break 'attempts;
}
Err(rbw::error::Error::TwoFactorRequired { providers }) => {
- if providers.contains(
- &rbw::api::TwoFactorProviderType::Authenticator,
- ) {
- let (
- access_token,
- refresh_token,
- iterations,
- protected_key,
- ) = two_factor(
- tty,
- &email,
- password.clone(),
- rbw::api::TwoFactorProviderType::Authenticator,
- )
- .await?;
- login_success(
- sock,
- state,
- access_token,
- refresh_token,
- iterations,
- protected_key,
- password,
- db,
- email,
- )
- .await?;
- break;
- } else {
- return Err(anyhow::anyhow!("TODO"));
+ let supported_types = vec![
+ rbw::api::TwoFactorProviderType::Authenticator,
+ rbw::api::TwoFactorProviderType::Yubikey,
+ rbw::api::TwoFactorProviderType::Email,
+ ];
+
+ for provider in supported_types {
+ if providers.contains(&provider) {
+ let (
+ access_token,
+ refresh_token,
+ kdf,
+ iterations,
+ memory,
+ parallelism,
+ protected_key,
+ ) = two_factor(
+ tty,
+ &email,
+ password.clone(),
+ provider,
+ )
+ .await?;
+ login_success(
+ state.clone(),
+ access_token,
+ refresh_token,
+ kdf,
+ iterations,
+ memory,
+ parallelism,
+ protected_key,
+ password,
+ db,
+ email,
+ )
+ .await?;
+ break 'attempts;
+ }
}
+ return Err(anyhow::anyhow!("TODO"));
}
Err(rbw::error::Error::IncorrectPassword { message }) => {
if i == 3 {
@@ -182,10 +192,9 @@ pub async fn login(
message,
})
.context("failed to log in to bitwarden instance");
- } else {
- err_msg = Some(message);
- continue;
}
+ err_msg = Some(message);
+ continue;
}
Err(e) => {
return Err(e)
@@ -205,7 +214,15 @@ async fn two_factor(
email: &str,
password: rbw::locked::Password,
provider: rbw::api::TwoFactorProviderType,
-) -> anyhow::Result<(String, String, u32, String)> {
+) -> anyhow::Result<(
+ String,
+ String,
+ rbw::api::KdfType,
+ u32,
+ Option<u32>,
+ Option<u32>,
+ String,
+)> {
let mut err_msg = None;
for i in 1_u8..=3 {
let err = if i > 1 {
@@ -217,11 +234,11 @@ async fn two_factor(
};
let code = rbw::pinentry::getpin(
&config_pinentry().await?,
- "Authenticator App",
- "Enter the 6 digit verification code from your authenticator app.",
+ provider.header(),
+ provider.message(),
err.as_deref(),
tty,
- true,
+ provider.grab(),
)
.await
.context("failed to read code from pinentry")?;
@@ -235,11 +252,22 @@ async fn two_factor(
)
.await
{
- Ok((access_token, refresh_token, iterations, protected_key)) => {
+ Ok((
+ access_token,
+ refresh_token,
+ kdf,
+ iterations,
+ memory,
+ parallelism,
+ protected_key,
+ )) => {
return Ok((
access_token,
refresh_token,
+ kdf,
iterations,
+ memory,
+ parallelism,
protected_key,
))
}
@@ -249,10 +277,9 @@ async fn two_factor(
message,
})
.context("failed to log in to bitwarden instance");
- } else {
- err_msg = Some(message);
- continue;
}
+ err_msg = Some(message);
+ continue;
}
// can get this if the user passes an empty string
Err(rbw::error::Error::TwoFactorRequired { .. }) => {
@@ -262,10 +289,9 @@ async fn two_factor(
message,
})
.context("failed to log in to bitwarden instance");
- } else {
- err_msg = Some(message);
- continue;
}
+ err_msg = Some(message);
+ continue;
}
Err(e) => {
return Err(e)
@@ -278,11 +304,13 @@ async fn two_factor(
}
async fn login_success(
- sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
access_token: String,
refresh_token: String,
+ kdf: rbw::api::KdfType,
iterations: u32,
+ memory: Option<u32>,
+ parallelism: Option<u32>,
protected_key: String,
password: rbw::locked::Password,
mut db: rbw::db::Db,
@@ -290,35 +318,37 @@ async fn login_success(
) -> anyhow::Result<()> {
db.access_token = Some(access_token.to_string());
db.refresh_token = Some(refresh_token.to_string());
+ db.kdf = Some(kdf);
db.iterations = Some(iterations);
+ db.memory = memory;
+ db.parallelism = parallelism;
db.protected_key = Some(protected_key.to_string());
save_db(&db).await?;
- sync(sock, false).await?;
+ sync(None, state.clone()).await?;
let db = load_db().await?;
- let protected_private_key =
- if let Some(protected_private_key) = db.protected_private_key {
- protected_private_key
- } else {
- return Err(anyhow::anyhow!(
- "failed to find protected private key in db"
- ));
- };
+ let Some(protected_private_key) = db.protected_private_key else {
+ return Err(anyhow::anyhow!(
+ "failed to find protected private key in db"
+ ));
+ };
let res = rbw::actions::unlock(
&email,
&password,
+ kdf,
iterations,
+ memory,
+ parallelism,
&protected_key,
&protected_private_key,
&db.protected_org_keys,
- )
- .await;
+ );
match res {
Ok((keys, org_keys)) => {
- let mut state = state.write().await;
+ let mut state = state.lock().await;
state.priv_key = Some(keys);
state.org_keys = Some(org_keys);
}
@@ -330,39 +360,40 @@ async fn login_success(
pub async fn unlock(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
tty: Option<&str>,
) -> anyhow::Result<()> {
- if state.read().await.needs_unlock() {
+ if state.lock().await.needs_unlock() {
let db = load_db().await?;
- let iterations = if let Some(iterations) = db.iterations {
- iterations
- } else {
+ let Some(kdf) = db.kdf else {
+ return Err(anyhow::anyhow!("failed to find kdf type in db"));
+ };
+
+ let Some(iterations) = db.iterations else {
return Err(anyhow::anyhow!(
"failed to find number of iterations in db"
));
};
- let protected_key = if let Some(protected_key) = db.protected_key {
- protected_key
- } else {
+
+ let memory = db.memory;
+ let parallelism = db.parallelism;
+
+ let Some(protected_key) = db.protected_key else {
return Err(anyhow::anyhow!(
"failed to find protected key in db"
));
};
- let protected_private_key =
- if let Some(protected_private_key) = db.protected_private_key {
- protected_private_key
- } else {
- return Err(anyhow::anyhow!(
- "failed to find protected private key in db"
- ));
- };
+ let Some(protected_private_key) = db.protected_private_key else {
+ return Err(anyhow::anyhow!(
+ "failed to find protected private key in db"
+ ));
+ };
let email = config_email().await?;
let mut err_msg = None;
- for i in 1u8..=3 {
+ for i in 1_u8..=3 {
let err = if i > 1 {
// this unwrap is safe because we only ever continue the loop
// if we have set err_msg
@@ -373,7 +404,10 @@ pub async fn unlock(
let password = rbw::pinentry::getpin(
&config_pinentry().await?,
"Master Password",
- "Unlock the local database",
+ &format!(
+ "Unlock the local database for '{}'",
+ rbw::dirs::profile()
+ ),
err.as_deref(),
tty,
true,
@@ -383,13 +417,14 @@ pub async fn unlock(
match rbw::actions::unlock(
&email,
&password,
+ kdf,
iterations,
+ memory,
+ parallelism,
&protected_key,
&protected_private_key,
&db.protected_org_keys,
- )
- .await
- {
+ ) {
Ok((keys, org_keys)) => {
unlock_success(state, keys, org_keys).await?;
break;
@@ -400,10 +435,9 @@ pub async fn unlock(
message,
})
.context("failed to unlock database");
- } else {
- err_msg = Some(message);
- continue;
}
+ err_msg = Some(message);
+ continue;
}
Err(e) => return Err(e).context("failed to unlock database"),
}
@@ -416,11 +450,11 @@ pub async fn unlock(
}
async fn unlock_success(
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
keys: rbw::locked::Keys,
org_keys: std::collections::HashMap<String, rbw::locked::Keys>,
) -> anyhow::Result<()> {
- let mut state = state.write().await;
+ let mut state = state.lock().await;
state.priv_key = Some(keys);
state.org_keys = Some(org_keys);
Ok(())
@@ -428,9 +462,9 @@ async fn unlock_success(
pub async fn lock(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
) -> anyhow::Result<()> {
- state.write().await.clear();
+ state.lock().await.clear();
respond_ack(sock).await?;
@@ -439,10 +473,9 @@ pub async fn lock(
pub async fn check_lock(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
- _tty: Option<&str>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
) -> anyhow::Result<()> {
- if state.read().await.needs_unlock() {
+ if state.lock().await.needs_unlock() {
return Err(anyhow::anyhow!("agent is locked"));
}
@@ -452,8 +485,8 @@ pub async fn check_lock(
}
pub async fn sync(
- sock: &mut crate::sock::Sock,
- ack: bool,
+ sock: Option<&mut crate::sock::Sock>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
) -> anyhow::Result<()> {
let mut db = load_db().await?;
@@ -482,7 +515,11 @@ pub async fn sync(
db.entries = entries;
save_db(&db).await?;
- if ack {
+ if let Err(e) = subscribe_to_notifications(state.clone()).await {
+ eprintln!("failed to subscribe to notifications: {e}");
+ }
+
+ if let Some(sock) = sock {
respond_ack(sock).await?;
}
@@ -491,14 +528,12 @@ pub async fn sync(
pub async fn decrypt(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
cipherstring: &str,
org_id: Option<&str>,
) -> anyhow::Result<()> {
- let state = state.read().await;
- let keys = if let Some(keys) = state.key(org_id) {
- keys
- } else {
+ let state = state.lock().await;
+ let Some(keys) = state.key(org_id) else {
return Err(anyhow::anyhow!(
"failed to find decryption keys in in-memory state"
));
@@ -519,14 +554,12 @@ pub async fn decrypt(
pub async fn encrypt(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<crate::agent::State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
plaintext: &str,
org_id: Option<&str>,
) -> anyhow::Result<()> {
- let state = state.read().await;
- let keys = if let Some(keys) = state.key(org_id) {
- keys
- } else {
+ let state = state.lock().await;
+ let Some(keys) = state.key(org_id) else {
return Err(anyhow::anyhow!(
"failed to find encryption keys in in-memory state"
));
@@ -542,6 +575,25 @@ pub async fn encrypt(
Ok(())
}
+pub async fn clipboard_store(
+ sock: &mut crate::sock::Sock,
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
+ text: &str,
+) -> anyhow::Result<()> {
+ state
+ .lock()
+ .await
+ .clipboard
+ .set_contents(text.to_owned())
+ .map_err(|e| {
+ anyhow::anyhow!("couldn't store value to clipboard: {e}")
+ })?;
+
+ respond_ack(sock).await?;
+
+ Ok(())
+}
+
pub async fn version(sock: &mut crate::sock::Sock) -> anyhow::Result<()> {
sock.send(&rbw::protocol::Response::Version {
version: rbw::protocol::version(),
@@ -579,11 +631,10 @@ async fn respond_encrypt(
async fn config_email() -> anyhow::Result<String> {
let config = rbw::config::Config::load_async().await?;
- if let Some(email) = config.email {
- Ok(email)
- } else {
- Err(anyhow::anyhow!("failed to find email address in config"))
- }
+ config.email.map_or_else(
+ || Err(anyhow::anyhow!("failed to find email address in config")),
+ Ok,
+ )
}
async fn load_db() -> anyhow::Result<rbw::db::Db> {
@@ -617,3 +668,35 @@ async fn config_pinentry() -> anyhow::Result<String> {
let config = rbw::config::Config::load_async().await?;
Ok(config.pinentry)
}
+
+pub async fn subscribe_to_notifications(
+ state: std::sync::Arc<tokio::sync::Mutex<crate::agent::State>>,
+) -> anyhow::Result<()> {
+ if state.lock().await.notifications_handler.is_connected() {
+ return Ok(());
+ }
+
+ let config = rbw::config::Config::load_async()
+ .await
+ .context("Config is missing")?;
+ let email = config.email.clone().context("Config is missing email")?;
+ let db = rbw::db::Db::load_async(config.server_name().as_str(), &email)
+ .await?;
+ let access_token =
+ db.access_token.context("Error getting access token")?;
+
+ let websocket_url = format!(
+ "{}/hub?access_token={}",
+ config.notifications_url(),
+ access_token
+ )
+ .replace("https://", "wss://");
+
+ let mut state = state.lock().await;
+ state
+ .notifications_handler
+ .connect(websocket_url)
+ .await
+ .err()
+ .map_or_else(|| Ok(()), |err| Err(anyhow::anyhow!(err.to_string())))
+}
diff --git a/src/bin/rbw-agent/agent.rs b/src/bin/rbw-agent/agent.rs
index fae8c7b..a3fecb4 100644
--- a/src/bin/rbw-agent/agent.rs
+++ b/src/bin/rbw-agent/agent.rs
@@ -1,24 +1,25 @@
use anyhow::Context as _;
+use futures_util::StreamExt as _;
-#[derive(Debug)]
-pub enum TimeoutEvent {
- Set,
- Clear,
-}
+use crate::notifications;
pub struct State {
pub priv_key: Option<rbw::locked::Keys>,
pub org_keys:
Option<std::collections::HashMap<String, rbw::locked::Keys>>,
- pub timeout_chan: tokio::sync::mpsc::UnboundedSender<TimeoutEvent>,
+ pub timeout: crate::timeout::Timeout,
+ pub timeout_duration: std::time::Duration,
+ pub sync_timeout: crate::timeout::Timeout,
+ pub sync_timeout_duration: std::time::Duration,
+ pub notifications_handler: crate::notifications::Handler,
+ pub clipboard: Box<dyn copypasta::ClipboardProvider>,
}
impl State {
pub fn key(&self, org_id: Option<&str>) -> Option<&rbw::locked::Keys> {
- match org_id {
- Some(id) => self.org_keys.as_ref().and_then(|h| h.get(id)),
- None => self.priv_key.as_ref(),
- }
+ org_id.map_or(self.priv_key.as_ref(), |id| {
+ self.org_keys.as_ref().and_then(|h| h.get(id))
+ })
}
pub fn needs_unlock(&self) -> bool {
@@ -26,103 +27,163 @@ impl State {
}
pub fn set_timeout(&mut self) {
- // no real better option to unwrap here
- self.timeout_chan.send(TimeoutEvent::Set).unwrap();
+ self.timeout.set(self.timeout_duration);
}
pub fn clear(&mut self) {
self.priv_key = None;
- self.org_keys = Default::default();
- // no real better option to unwrap here
- self.timeout_chan.send(TimeoutEvent::Clear).unwrap();
+ self.org_keys = None;
+ self.timeout.clear();
+ }
+
+ pub fn set_sync_timeout(&mut self) {
+ self.sync_timeout.set(self.sync_timeout_duration);
}
}
pub struct Agent {
- timeout_duration: tokio::time::Duration,
- timeout: Option<std::pin::Pin<Box<tokio::time::Sleep>>>,
- timeout_chan: tokio::sync::mpsc::UnboundedReceiver<TimeoutEvent>,
- state: std::sync::Arc<tokio::sync::RwLock<State>>,
+ timer_r: tokio::sync::mpsc::UnboundedReceiver<()>,
+ sync_timer_r: tokio::sync::mpsc::UnboundedReceiver<()>,
+ state: std::sync::Arc<tokio::sync::Mutex<State>>,
}
impl Agent {
pub fn new() -> anyhow::Result<Self> {
let config = rbw::config::Config::load()?;
let timeout_duration =
- tokio::time::Duration::from_secs(config.lock_timeout);
- let (w, r) = tokio::sync::mpsc::unbounded_channel();
+ std::time::Duration::from_secs(config.lock_timeout);
+ let sync_timeout_duration =
+ std::time::Duration::from_secs(config.sync_interval);
+ let (timeout, timer_r) = crate::timeout::Timeout::new();
+ let (sync_timeout, sync_timer_r) = crate::timeout::Timeout::new();
+ if sync_timeout_duration > std::time::Duration::ZERO {
+ sync_timeout.set(sync_timeout_duration);
+ }
+ let notifications_handler = crate::notifications::Handler::new();
+ let clipboard: Box<dyn copypasta::ClipboardProvider> =
+ copypasta::ClipboardContext::new().map_or_else(
+ |e| {
+ log::warn!("couldn't create clipboard context: {e}");
+ let clipboard = Box::new(
+ // infallible
+ copypasta::nop_clipboard::NopClipboardContext::new()
+ .unwrap(),
+ );
+ let clipboard: Box<dyn copypasta::ClipboardProvider> =
+ clipboard;
+ clipboard
+ },
+ |clipboard| {
+ let clipboard = Box::new(clipboard);
+ let clipboard: Box<dyn copypasta::ClipboardProvider> =
+ clipboard;
+ clipboard
+ },
+ );
Ok(Self {
- timeout_duration,
- timeout: None,
- timeout_chan: r,
- state: std::sync::Arc::new(tokio::sync::RwLock::new(State {
+ timer_r,
+ sync_timer_r,
+ state: std::sync::Arc::new(tokio::sync::Mutex::new(State {
priv_key: None,
- org_keys: Default::default(),
- timeout_chan: w,
+ org_keys: None,
+ timeout,
+ timeout_duration,
+ sync_timeout,
+ sync_timeout_duration,
+ notifications_handler,
+ clipboard,
})),
})
}
- fn set_timeout(&mut self) {
- self.timeout =
- Some(Box::pin(tokio::time::sleep(self.timeout_duration)));
- }
-
- fn clear_timeout(&mut self) {
- self.timeout = None;
- }
-
pub async fn run(
- &mut self,
+ self,
listener: tokio::net::UnixListener,
) -> anyhow::Result<()> {
- // tokio only supports timeouts up to 2^36 milliseconds
- let mut forever = Box::pin(tokio::time::sleep(
- tokio::time::Duration::from_secs(60 * 60 * 24 * 365 * 2),
- ));
- loop {
- let timeout = if let Some(timeout) = &mut self.timeout {
- timeout
- } else {
- &mut forever
- };
- tokio::select! {
- sock = listener.accept() => {
+ pub enum Event {
+ Request(std::io::Result<tokio::net::UnixStream>),
+ Timeout(()),
+ Sync(()),
+ }
+
+ let notifications = self
+ .state
+ .lock()
+ .await
+ .notifications_handler
+ .get_channel()
+ .await;
+ let notifications =
+ tokio_stream::wrappers::UnboundedReceiverStream::new(
+ notifications,
+ )
+ .map(|message| match message {
+ notifications::Message::Logout => Event::Timeout(()),
+ notifications::Message::Sync => Event::Sync(()),
+ })
+ .boxed();
+
+ let mut stream = futures_util::stream::select_all([
+ tokio_stream::wrappers::UnixListenerStream::new(listener)
+ .map(Event::Request)
+ .boxed(),
+ tokio_stream::wrappers::UnboundedReceiverStream::new(
+ self.timer_r,
+ )
+ .map(Event::Timeout)
+ .boxed(),
+ tokio_stream::wrappers::UnboundedReceiverStream::new(
+ self.sync_timer_r,
+ )
+ .map(Event::Sync)
+ .boxed(),
+ notifications,
+ ]);
+ while let Some(event) = stream.next().await {
+ match event {
+ Event::Request(res) => {
let mut sock = crate::sock::Sock::new(
- sock.context("failed to accept incoming connection")?.0
+ res.context("failed to accept incoming connection")?,
);
let state = self.state.clone();
tokio::spawn(async move {
- let res
- = handle_request(&mut sock, state.clone()).await;
+ let res =
+ handle_request(&mut sock, state.clone()).await;
if let Err(e) = res {
// unwrap is the only option here
sock.send(&rbw::protocol::Response::Error {
- error: format!("{:#}", e),
- }).await.unwrap();
+ error: format!("{e:#}"),
+ })
+ .await
+ .unwrap();
}
});
}
- _ = timeout => {
+ Event::Timeout(()) => {
+ self.state.lock().await.clear();
+ }
+ Event::Sync(()) => {
let state = self.state.clone();
- tokio::spawn(async move{
- state.write().await.clear();
+ tokio::spawn(async move {
+ // this could fail if we aren't logged in, but we
+ // don't care about that
+ if let Err(e) =
+ crate::actions::sync(None, state.clone()).await
+ {
+ eprintln!("failed to sync: {e:#}");
+ }
});
- }
- Some(ev) = self.timeout_chan.recv() => {
- match ev {
- TimeoutEvent::Set => self.set_timeout(),
- TimeoutEvent::Clear => self.clear_timeout(),
- }
+ self.state.lock().await.set_sync_timeout();
}
}
}
+ Ok(())
}
}
async fn handle_request(
sock: &mut crate::sock::Sock,
- state: std::sync::Arc<tokio::sync::RwLock<State>>,
+ state: std::sync::Arc<tokio::sync::Mutex<State>>,
) -> anyhow::Result<()> {
let req = sock.recv().await?;
let req = match req {
@@ -148,12 +209,7 @@ async fn handle_request(
true
}
rbw::protocol::Action::CheckLock => {
- crate::actions::check_lock(
- sock,
- state.clone(),
- req.tty.as_deref(),
- )
- .await?;
+ crate::actions::check_lock(sock, state.clone()).await?;
false
}
rbw::protocol::Action::Lock => {
@@ -161,7 +217,7 @@ async fn handle_request(
false
}
rbw::protocol::Action::Sync => {
- crate::actions::sync(sock, true).await?;
+ crate::actions::sync(Some(sock), state.clone()).await?;
false
}
rbw::protocol::Action::Decrypt {
@@ -187,6 +243,11 @@ async fn handle_request(
.await?;
true
}
+ rbw::protocol::Action::ClipboardStore { text } => {
+ crate::actions::clipboard_store(sock, state.clone(), text)
+ .await?;
+ true
+ }
rbw::protocol::Action::Quit => std::process::exit(0),
rbw::protocol::Action::Version => {
crate::actions::version(sock).await?;
@@ -195,7 +256,7 @@ async fn handle_request(
};
if set_timeout {
- state.write().await.set_timeout();
+ state.lock().await.set_timeout();
}
Ok(())
diff --git a/src/bin/rbw-agent/daemon.rs b/src/bin/rbw-agent/daemon.rs
index 923a217..06db891 100644
--- a/src/bin/rbw-agent/daemon.rs
+++ b/src/bin/rbw-agent/daemon.rs
@@ -1,25 +1,15 @@
pub struct StartupAck {
- writer: std::os::unix::io::RawFd,
+ writer: std::os::unix::io::OwnedFd,
}
impl StartupAck {
- pub fn ack(&self) -> anyhow::Result<()> {
- nix::unistd::write(self.writer, &[0])?;
- nix::unistd::close(self.writer)?;
+ pub fn ack(self) -> anyhow::Result<()> {
+ rustix::io::write(&self.writer, &[0])?;
Ok(())
}
}
-impl Drop for StartupAck {
- fn drop(&mut self) {
- // best effort close here, can't do better in a destructor
- let _ = nix::unistd::close(self.writer);
- }
-}
-
pub fn daemonize() -> anyhow::Result<StartupAck> {
- rbw::dirs::make_all()?;
-
let stdout = std::fs::OpenOptions::new()
.append(true)
.create(true)
@@ -29,33 +19,38 @@ pub fn daemonize() -> anyhow::Result<StartupAck> {
.create(true)
.open(rbw::dirs::agent_stderr_file())?;
- let (r, w) = nix::unistd::pipe()?;
- let res = daemonize::Daemonize::new()
+ let (r, w) = rustix::pipe::pipe()?;
+ let daemonize = daemonize::Daemonize::new()
.pid_file(rbw::dirs::pid_file())
.stdout(stdout)
- .stderr(stderr)
- .exit_action(move || {
+ .stderr(stderr);
+ let res = match daemonize.execute() {
+ daemonize::Outcome::Parent(_) => {
+ drop(w);
+ let mut buf = [0; 1];
// unwraps are necessary because not really a good way to handle
// errors here otherwise
- let _ = nix::unistd::close(w);
- let mut buf = [0; 1];
- nix::unistd::read(r, &mut buf).unwrap();
- nix::unistd::close(r).unwrap();
- })
- .start();
- let _ = nix::unistd::close(r);
+ rustix::io::read(&r, &mut buf).unwrap();
+ drop(r);
+ std::process::exit(0);
+ }
+ daemonize::Outcome::Child(res) => res,
+ };
+
+ drop(r);
match res {
Ok(_) => (),
Err(e) => {
- match e {
- daemonize::DaemonizeError::LockPidfile(_) => {
- // this means that there is already an agent running, so
- // return a special exit code to allow the cli to detect
- // this case and not error out
- std::process::exit(23);
- }
- _ => panic!("failed to daemonize: {}", e),
+ // XXX super gross, but daemonize removed the ability to match
+ // on specific error types for some reason?
+ if e.to_string().contains("unable to lock pid file") {
+ // this means that there is already an agent running, so
+ // return a special exit code to allow the cli to detect
+ // this case and not error out
+ std::process::exit(23);
+ } else {
+ panic!("failed to daemonize: {e}");
}
}
}
diff --git a/src/bin/rbw-agent/debugger.rs b/src/bin/rbw-agent/debugger.rs
index 59bbe50..be5260c 100644
--- a/src/bin/rbw-agent/debugger.rs
+++ b/src/bin/rbw-agent/debugger.rs
@@ -12,7 +12,7 @@ pub fn disable_tracing() -> anyhow::Result<()> {
if ret == 0 {
Ok(())
} else {
- let e = nix::Error::last();
+ let e = std::io::Error::last_os_error();
Err(anyhow::anyhow!("failed to disable PTRACE_ATTACH, agent memory may be dumpable by other processes: {}", e))
}
}
diff --git a/src/bin/rbw-agent/main.rs b/src/bin/rbw-agent/main.rs
index 69411ae..d470e10 100644
--- a/src/bin/rbw-agent/main.rs
+++ b/src/bin/rbw-agent/main.rs
@@ -1,4 +1,19 @@
+#![warn(clippy::cargo)]
+#![warn(clippy::pedantic)]
+#![warn(clippy::nursery)]
+#![warn(clippy::as_conversions)]
+#![warn(clippy::get_unwrap)]
+#![allow(clippy::cognitive_complexity)]
+#![allow(clippy::missing_const_for_fn)]
+#![allow(clippy::similar_names)]
+#![allow(clippy::struct_excessive_bools)]
#![allow(clippy::too_many_arguments)]
+#![allow(clippy::too_many_lines)]
+#![allow(clippy::type_complexity)]
+#![allow(clippy::multiple_crate_versions)]
+#![allow(clippy::large_enum_variant)]
+// this one looks plausibly useful, but currently has too many bugs
+#![allow(clippy::significant_drop_tightening)]
use anyhow::Context as _;
@@ -6,7 +21,9 @@ mod actions;
mod agent;
mod daemon;
mod debugger;
+mod notifications;
mod sock;
+mod timeout;
async fn tokio_main(
startup_ack: Option<crate::daemon::StartupAck>,
@@ -17,7 +34,7 @@ async fn tokio_main(
startup_ack.ack()?;
}
- let mut agent = crate::agent::Agent::new()?;
+ let agent = crate::agent::Agent::new()?;
agent.run(listener).await?;
Ok(())
@@ -29,11 +46,11 @@ fn real_main() -> anyhow::Result<()> {
)
.init();
- let no_daemonize = if let Some(arg) = std::env::args().nth(1) {
- arg == "--no-daemonize"
- } else {
- false
- };
+ let no_daemonize = std::env::args()
+ .nth(1)
+ .map_or(false, |arg| arg == "--no-daemonize");
+
+ rbw::dirs::make_all()?;
let startup_ack = if no_daemonize {
None
@@ -69,7 +86,7 @@ fn main() {
if let Err(e) = res {
// XXX log file?
- eprintln!("{:#}", e);
+ eprintln!("{e:#}");
std::process::exit(1);
}
}
diff --git a/src/bin/rbw-agent/notifications.rs b/src/bin/rbw-agent/notifications.rs
new file mode 100644
index 0000000..8176603
--- /dev/null
+++ b/src/bin/rbw-agent/notifications.rs
@@ -0,0 +1,174 @@
+use futures_util::{SinkExt as _, StreamExt as _};
+
+#[derive(Clone, Copy, Debug)]
+pub enum Message {
+ Sync,
+ Logout,
+}
+
+pub struct Handler {
+ write: Option<
+ futures::stream::SplitSink<
+ tokio_tungstenite::WebSocketStream<
+ tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
+ >,
+ tokio_tungstenite::tungstenite::Message,
+ >,
+ >,
+ read_handle: Option<tokio::task::JoinHandle<()>>,
+ sending_channels: std::sync::Arc<
+ tokio::sync::RwLock<Vec<tokio::sync::mpsc::UnboundedSender<Message>>>,
+ >,
+}
+
+impl Handler {
+ pub fn new() -> Self {
+ Self {
+ write: None,
+ read_handle: None,
+ sending_channels: std::sync::Arc::new(tokio::sync::RwLock::new(
+ Vec::new(),
+ )),
+ }
+ }
+
+ pub async fn connect(
+ &mut self,
+ url: String,
+ ) -> Result<(), Box<dyn std::error::Error>> {
+ if self.is_connected() {
+ self.disconnect().await?;
+ }
+
+ let (write, read_handle) =
+ subscribe_to_notifications(url, self.sending_channels.clone())
+ .await?;
+
+ self.write = Some(write);
+ self.read_handle = Some(read_handle);
+ Ok(())
+ }
+
+ pub fn is_connected(&self) -> bool {
+ self.write.is_some()
+ && self.read_handle.is_some()
+ && !self.read_handle.as_ref().unwrap().is_finished()
+ }
+
+ pub async fn disconnect(
+ &mut self,
+ ) -> Result<(), Box<dyn std::error::Error>> {
+ self.sending_channels.write().await.clear();
+ if let Some(mut write) = self.write.take() {
+ write
+ .send(tokio_tungstenite::tungstenite::Message::Close(None))
+ .await?;
+ write.close().await?;
+ self.read_handle.take().unwrap().await?;
+ }
+ self.write = None;
+ self.read_handle = None;
+ Ok(())
+ }
+
+ pub async fn get_channel(
+ &mut self,
+ ) -> tokio::sync::mpsc::UnboundedReceiver<Message> {
+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
+ self.sending_channels.write().await.push(tx);
+ rx
+ }
+}
+
+async fn subscribe_to_notifications(
+ url: String,
+ sending_channels: std::sync::Arc<
+ tokio::sync::RwLock<Vec<tokio::sync::mpsc::UnboundedSender<Message>>>,
+ >,
+) -> Result<
+ (
+ futures_util::stream::SplitSink<
+ tokio_tungstenite::WebSocketStream<
+ tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
+ >,
+ tokio_tungstenite::tungstenite::Message,
+ >,
+ tokio::task::JoinHandle<()>,
+ ),
+ Box<dyn std::error::Error>,
+> {
+ let url = url::Url::parse(url.as_str())?;
+ let (ws_stream, _response) =
+ tokio_tungstenite::connect_async(url).await?;
+ let (mut write, read) = ws_stream.split();
+
+ write
+ .send(tokio_tungstenite::tungstenite::Message::Text(
+ "{\"protocol\":\"messagepack\",\"version\":1}\x1e".to_string(),
+ ))
+ .await
+ .unwrap();
+
+ let read_future = async move {
+ let sending_channels = &sending_channels;
+ read.for_each(|message| async move {
+ match message {
+ Ok(message) => {
+ if let Some(message) = parse_message(message) {
+ let sending_channels = sending_channels.read().await;
+ let sending_channels = sending_channels.as_slice();
+ for channel in sending_channels {
+ channel.send(message).unwrap();
+ }
+ }
+ }
+ Err(e) => {
+ eprintln!("websocket error: {e:?}");
+ }
+ }
+ })
+ .await;
+ };
+
+ Ok((write, tokio::spawn(read_future)))
+}
+
+fn parse_message(
+ message: tokio_tungstenite::tungstenite::Message,
+) -> Option<Message> {
+ let tokio_tungstenite::tungstenite::Message::Binary(data) = message
+ else {
+ return None;
+ };
+
+ // the first few bytes with the 0x80 bit set, plus one byte terminating the length contain the length of the message
+ let len_buffer_length = data.iter().position(|&x| (x & 0x80) == 0)? + 1;
+
+ let unpacked_messagepack =
+ rmpv::decode::read_value(&mut &data[len_buffer_length..]).ok()?;
+
+ let unpacked_message = unpacked_messagepack.as_array()?;
+ let message_type = unpacked_message.first()?.as_u64()?;
+ // invocation
+ if message_type != 1 {
+ return None;
+ }
+ let target = unpacked_message.get(3)?.as_str()?;
+ if target != "ReceiveMessage" {
+ return None;
+ }
+
+ let args = unpacked_message.get(4)?.as_array()?;
+ let map = args.first()?.as_map()?;
+ for (k, v) in map {
+ if k.as_str()? == "Type" {
+ let ty = v.as_i64()?;
+ return match ty {
+ 11 => Some(Message::Logout),
+ _ => Some(Message::Sync),
+ };
+ }
+ }
+
+ None
+}
diff --git a/src/bin/rbw-agent/sock.rs b/src/bin/rbw-agent/sock.rs
index 311176c..280b8cc 100644
--- a/src/bin/rbw-agent/sock.rs
+++ b/src/bin/rbw-agent/sock.rs
@@ -36,9 +36,8 @@ impl Sock {
buf.read_line(&mut line)
.await
.context("failed to read message from socket")?;
- Ok(serde_json::from_str(&line).map_err(|e| {
- format!("failed to parse message '{}': {}", line, e)
- }))
+ Ok(serde_json::from_str(&line)
+ .map_err(|e| format!("failed to parse message '{line}': {e}")))
}
}
diff --git a/src/bin/rbw-agent/timeout.rs b/src/bin/rbw-agent/timeout.rs
new file mode 100644
index 0000000..e2aba06
--- /dev/null
+++ b/src/bin/rbw-agent/timeout.rs
@@ -0,0 +1,66 @@
+use futures_util::StreamExt as _;
+
+#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)]
+enum Streams {
+ Requests,
+ Timer,
+}
+
+#[derive(Debug)]
+enum Action {
+ Set(std::time::Duration),
+ Clear,
+}
+
+pub struct Timeout {
+ req_w: tokio::sync::mpsc::UnboundedSender<Action>,
+}
+
+impl Timeout {
+ pub fn new() -> (Self, tokio::sync::mpsc::UnboundedReceiver<()>) {
+ let (req_w, req_r) = tokio::sync::mpsc::unbounded_channel();
+ let (timer_w, timer_r) = tokio::sync::mpsc::unbounded_channel();
+ tokio::spawn(async move {
+ enum Event {
+ Request(Action),
+ Timer,
+ }
+ let mut stream = tokio_stream::StreamMap::new();
+ stream.insert(
+ Streams::Requests,
+ tokio_stream::wrappers::UnboundedReceiverStream::new(req_r)
+ .map(Event::Request)
+ .boxed(),
+ );
+ while let Some(event) = stream.next().await {
+ match event {
+ (_, Event::Request(Action::Set(dur))) => {
+ stream.insert(
+ Streams::Timer,
+ futures_util::stream::once(tokio::time::sleep(
+ dur,
+ ))
+ .map(|()| Event::Timer)
+ .boxed(),
+ );
+ }
+ (_, Event::Request(Action::Clear)) => {
+ stream.remove(&Streams::Timer);
+ }
+ (_, Event::Timer) => {
+ timer_w.send(()).unwrap();
+ }
+ }
+ }
+ });
+ (Self { req_w }, timer_r)
+ }
+
+ pub fn set(&self, dur: std::time::Duration) {
+ self.req_w.send(Action::Set(dur)).unwrap();
+ }
+
+ pub fn clear(&self) {
+ self.req_w.send(Action::Clear).unwrap();
+ }
+}
diff --git a/src/bin/rbw/actions.rs b/src/bin/rbw/actions.rs
index 39fde15..c84ccd4 100644
--- a/src/bin/rbw/actions.rs
+++ b/src/bin/rbw/actions.rs
@@ -1,4 +1,4 @@
-use anyhow::Context as _;
+use anyhow::{bail, Context as _};
use std::io::Read as _;
pub fn register() -> anyhow::Result<()> {
@@ -31,11 +31,17 @@ pub fn quit() -> anyhow::Result<()> {
let pidfile = rbw::dirs::pid_file();
let mut pid = String::new();
std::fs::File::open(pidfile)?.read_to_string(&mut pid)?;
- let pid = nix::unistd::Pid::from_raw(pid.parse()?);
+ let Some(pid) =
+ rustix::process::Pid::from_raw(pid.trim_end().parse()?)
+ else {
+ bail!("failed to read pid from pidfile");
+ };
sock.send(&rbw::protocol::Request {
- tty: nix::unistd::ttyname(0)
+ tty: rustix::termios::ttyname(std::io::stdin(), vec![])
.ok()
- .and_then(|p| p.to_str().map(|s| s.to_string())),
+ .and_then(|p| {
+ p.to_str().map(std::string::ToString::to_string).ok()
+ }),
action: rbw::protocol::Action::Quit,
})?;
wait_for_exit(pid);
@@ -57,9 +63,11 @@ pub fn decrypt(
) -> anyhow::Result<String> {
let mut sock = connect()?;
sock.send(&rbw::protocol::Request {
- tty: nix::unistd::ttyname(0)
+ tty: rustix::termios::ttyname(std::io::stdin(), vec![])
.ok()
- .and_then(|p| p.to_str().map(|s| s.to_string())),
+ .and_then(|p| {
+ p.to_str().map(std::string::ToString::to_string).ok()
+ }),
action: rbw::protocol::Action::Decrypt {
cipherstring: cipherstring.to_string(),
org_id: org_id.map(std::string::ToString::to_string),
@@ -82,9 +90,11 @@ pub fn encrypt(
) -> anyhow::Result<String> {
let mut sock = connect()?;
sock.send(&rbw::protocol::Request {
- tty: nix::unistd::ttyname(0)
+ tty: rustix::termios::ttyname(std::io::stdin(), vec![])
.ok()
- .and_then(|p| p.to_str().map(|s| s.to_string())),
+ .and_then(|p| {
+ p.to_str().map(std::string::ToString::to_string).ok()
+ }),
action: rbw::protocol::Action::Encrypt {
plaintext: plaintext.to_string(),
org_id: org_id.map(std::string::ToString::to_string),
@@ -101,12 +111,20 @@ pub fn encrypt(
}
}
+pub fn clipboard_store(text: &str) -> anyhow::Result<()> {
+ simple_action(rbw::protocol::Action::ClipboardStore {
+ text: text.to_string(),
+ })
+}
+
pub fn version() -> anyhow::Result<u32> {
let mut sock = connect()?;
sock.send(&rbw::protocol::Request {
- tty: nix::unistd::ttyname(0)
+ tty: rustix::termios::ttyname(std::io::stdin(), vec![])
.ok()
- .and_then(|p| p.to_str().map(|s| s.to_string())),
+ .and_then(|p| {
+ p.to_str().map(std::string::ToString::to_string).ok()
+ }),
action: rbw::protocol::Action::Version,
})?;
@@ -124,9 +142,11 @@ fn simple_action(action: rbw::protocol::Action) -> anyhow::Result<()> {
let mut sock = connect()?;
sock.send(&rbw::protocol::Request {
- tty: nix::unistd::ttyname(0)
+ tty: rustix::termios::ttyname(std::io::stdin(), vec![])
.ok()
- .and_then(|p| p.to_str().map(|s| s.to_string())),
+ .and_then(|p| {
+ p.to_str().map(std::string::ToString::to_string).ok()
+ }),
action,
})?;
@@ -152,9 +172,9 @@ fn connect() -> anyhow::Result<crate::sock::Sock> {
})
}
-fn wait_for_exit(pid: nix::unistd::Pid) {
+fn wait_for_exit(pid: rustix::process::Pid) {
loop {
- if nix::sys::signal::kill(pid, None).is_err() {
+ if rustix::process::test_kill_process(pid).is_err() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
diff --git a/src/bin/rbw/commands.rs b/src/bin/rbw/commands.rs
index 9efd966..6d36eb3 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 |=
+ display_field("Address", address2.as_deref(), clipboard);
displayed |=
- self.display_field("Address", address2.as_deref());
+ display_field("Address", address3.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("City", city.as_deref(), clipboard);
displayed |=
- self.display_field("Postcode", postal_code.as_deref());
+ display_field("State", state.as_deref(), clipboard);
+ displayed |= display_field(
+ "Postcode",
+ postal_code.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("Country", country.as_deref(), clipboard);
displayed |=
- self.display_field("License", license_number.as_deref());
- displayed |= self
- .display_field("Passport", passport_number.as_deref());
+ display_field("Phone", phone.as_deref(), clipboard);
displayed |=
- self.display_field("Username", username.as_deref());
+ 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,26 +998,30 @@ 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(())
}
pub fn code(
- name: &str,
+ needle: &Needle,
user: Option<&str>,
folder: Option<&str>,
+ clipboard: bool,
) -> anyhow::Result<()> {
unlock()?;
@@ -584,17 +1029,16 @@ pub fn code(
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))?;
+ let (_, decrypted) = find_entry(&db, needle, 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 +1054,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 +1071,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 +1151,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 +1257,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 +1290,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 +1316,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 +1355,7 @@ pub fn edit(
entry.org_id.as_deref(),
&entry.name,
&data,
+ &fields,
notes.as_deref(),
entry.folder_id.as_deref(),
&history,
@@ -918,14 +1381,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 +1412,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 +1478,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 +1513,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 +1558,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 +1569,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 +1896,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,36 +1911,54 @@ 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>> {
- let secret_str = if let Ok(u) = url::Url::parse(secret) {
+struct TotpParams {
+ secret: Vec<u8>,
+ algorithm: String,
+ digits: u32,
+ period: u64,
+}
+
+fn decode_totp_secret(secret: &str) -> anyhow::Result<Vec<u8>> {
+ base32::decode(
+ base32::Alphabet::RFC4648 { padding: false },
+ &secret.replace(' ', ""),
+ )
+ .ok_or_else(|| anyhow::anyhow!("totp secret was not valid base32"))
+}
+
+fn parse_totp_secret(secret: &str) -> anyhow::Result<TotpParams> {
+ if let Ok(u) = url::Url::parse(secret) {
if u.scheme() != "otpauth" {
return Err(anyhow::anyhow!(
"totp secret url must have otpauth scheme"
@@ -1489,32 +1971,80 @@ fn parse_totp_secret(secret: &str) -> anyhow::Result<Vec<u8>> {
}
let query: std::collections::HashMap<_, _> =
u.query_pairs().collect();
- query
- .get("secret")
- .ok_or_else(|| {
- anyhow::anyhow!("totp secret url must have secret")
- })?
- .to_string()
+ Ok(TotpParams {
+ secret: decode_totp_secret(query
+ .get("secret")
+ .ok_or_else(|| {
+ anyhow::anyhow!("totp secret url must have secret")
+ })?)?,
+ algorithm:query.get("algorithm").map_or_else(||{String::from("SHA1")},|alg|{alg.to_string()} ),
+ digits: match query.get("digits") {
+ Some(dig) => {
+ dig.parse::<u32>().map_err(|_|{
+ anyhow::anyhow!("digits parameter in totp url must be a valid integer.")
+ })?
+ }
+ None => 6,
+ },
+ period: match query.get("period") {
+ Some(dig) => {
+ dig.parse::<u64>().map_err(|_|{
+ anyhow::anyhow!("period parameter in totp url must be a valid integer.")
+ })?
+ }
+ None => totp_lite::DEFAULT_STEP,
+ }
+ })
} else {
- secret.to_string()
- };
- base32::decode(
- base32::Alphabet::RFC4648 { padding: false },
- &secret_str.replace(" ", ""),
- )
- .ok_or_else(|| anyhow::anyhow!("totp secret was not valid base32"))
+ Ok(TotpParams {
+ secret: decode_totp_secret(secret)?,
+ algorithm: String::from("SHA1"),
+ digits: 6,
+ period: totp_lite::DEFAULT_STEP,
+ })
+ }
}
fn generate_totp(secret: &str) -> anyhow::Result<String> {
- let key = parse_totp_secret(secret)?;
- Ok(totp_lite::totp_custom::<totp_lite::Sha1>(
- totp_lite::DEFAULT_STEP,
- 6,
- &key,
- std::time::SystemTime::now()
- .duration_since(std::time::SystemTime::UNIX_EPOCH)?
- .as_secs(),
- ))
+ let totp_params = parse_totp_secret(secret)?;
+ let alg = totp_params.algorithm.as_str();
+ match alg {
+ "SHA1" => Ok(totp_lite::totp_custom::<totp_lite::Sha1>(
+ totp_params.period,
+ totp_params.digits,
+ &totp_params.secret,
+ std::time::SystemTime::now()
+ .duration_since(std::time::SystemTime::UNIX_EPOCH)?
+ .as_secs(),
+ )),
+ "SHA256" => Ok(totp_lite::totp_custom::<totp_lite::Sha256>(
+ totp_params.period,
+ totp_params.digits,
+ &totp_params.secret,
+ std::time::SystemTime::now()
+ .duration_since(std::time::SystemTime::UNIX_EPOCH)?
+ .as_secs(),
+ )),
+ "SHA512" => Ok(totp_lite::totp_custom::<totp_lite::Sha512>(
+ totp_params.period,
+ totp_params.digits,
+ &totp_params.secret,
+ std::time::SystemTime::now()
+ .duration_since(std::time::SystemTime::UNIX_EPOCH)?
+ .as_secs(),
+ )),
+ _ => Err(anyhow::anyhow!(format!(
+ "{} is not a valid totp algorithm",
+ alg
+ ))),
+ }
+}
+
+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)]
@@ -1524,15 +2054,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 +2121,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 +2807,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 +2822,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 +2836,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,
diff --git a/src/bin/rbw/main.rs b/src/bin/rbw/main.rs
index 85631c5..2fb96bf 100644
--- a/src/bin/rbw/main.rs
+++ b/src/bin/rbw/main.rs
@@ -1,23 +1,36 @@
+#![warn(clippy::cargo)]
+#![warn(clippy::pedantic)]
+#![warn(clippy::nursery)]
+#![warn(clippy::as_conversions)]
+#![warn(clippy::get_unwrap)]
+#![allow(clippy::cognitive_complexity)]
+#![allow(clippy::missing_const_for_fn)]
+#![allow(clippy::similar_names)]
+#![allow(clippy::struct_excessive_bools)]
+#![allow(clippy::too_many_arguments)]
+#![allow(clippy::too_many_lines)]
+#![allow(clippy::type_complexity)]
+#![allow(clippy::multiple_crate_versions)]
#![allow(clippy::large_enum_variant)]
use anyhow::Context as _;
+use clap::{CommandFactory as _, Parser as _};
use std::io::Write as _;
-use structopt::StructOpt as _;
mod actions;
mod commands;
mod sock;
-#[derive(Debug, structopt::StructOpt)]
-#[structopt(about = "Unofficial Bitwarden CLI")]
+#[derive(Debug, clap::Parser)]
+#[command(version, about = "Unofficial Bitwarden CLI")]
enum Opt {
- #[structopt(about = "Get or set configuration options")]
+ #[command(about = "Get or set configuration options")]
Config {
- #[structopt(subcommand)]
+ #[command(subcommand)]
config: Config,
},
- #[structopt(
+ #[command(
about = "Register this device with the Bitwarden server",
long_about = "Register this device with the Bitwarden server\n\n\
The official Bitwarden server includes bot detection to prevent \
@@ -28,60 +41,68 @@ enum Opt {
)]
Register,
- #[structopt(about = "Log in to the Bitwarden server")]
+ #[command(about = "Log in to the Bitwarden server")]
Login,
- #[structopt(about = "Unlock the local Bitwarden database")]
+ #[command(about = "Unlock the local Bitwarden database")]
Unlock,
- #[structopt(about = "Check if the local Bitwarden database is unlocked")]
+ #[command(about = "Check if the local Bitwarden database is unlocked")]
Unlocked,
- #[structopt(about = "Update the local copy of the Bitwarden database")]
+ #[command(about = "Update the local copy of the Bitwarden database")]
Sync,
- #[structopt(
+ #[command(
about = "List all entries in the local Bitwarden database",
visible_alias = "ls"
)]
List {
- #[structopt(
+ #[arg(
long,
help = "Fields to display. \
Available options are id, name, user, folder. \
Multiple fields will be separated by tabs.",
default_value = "name",
- use_delimiter = true
+ use_value_delimiter = true
)]
fields: Vec<String>,
},
- #[structopt(about = "Display the password for a given entry")]
+ #[command(about = "Display the password for a given entry")]
Get {
- #[structopt(help = "Name or UUID of the entry to display")]
- name: String,
- #[structopt(help = "Username of the entry to display")]
+ #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)]
+ needle: commands::Needle,
+ #[arg(help = "Username of the entry to display")]
user: Option<String>,
- #[structopt(long, help = "Folder name to search in")]
+ #[arg(long, help = "Folder name to search in")]
folder: Option<String>,
- #[structopt(
- long,
- help = "Display the notes in addition to the password"
- )]
+ #[arg(short, long, help = "Field to get")]
+ field: Option<String>,
+ #[arg(long, help = "Display the notes in addition to the password")]
full: bool,
+ #[structopt(long, help = "Display output as JSON")]
+ raw: bool,
+ #[structopt(long, help = "Copy result to clipboard")]
+ clipboard: bool,
},
- #[structopt(about = "Display the authenticator code for a given entry")]
+ #[command(
+ about = "Display the authenticator code for a given entry",
+ visible_alias = "totp"
+ )]
Code {
- #[structopt(help = "Name or UUID of the entry to display")]
- name: String,
- #[structopt(help = "Username of the entry to display")]
+ #[arg(help = "Name, URI or UUID of the entry to display", value_parser = commands::parse_needle)]
+ needle: commands::Needle,
+ #[arg(help = "Username of the entry to display")]
user: Option<String>,
- #[structopt(long, help = "Folder name to search in")]
+ #[arg(long, help = "Folder name to search in")]
folder: Option<String>,
+ #[structopt(long, help = "Copy result to clipboard")]
+ clipboard: bool,
},
- #[structopt(
+ #[command(
about = "Add a new password to the database",
long_about = "Add a new password to the database\n\n\
This command will open a text editor to enter \
@@ -91,28 +112,27 @@ enum Opt {
remainder will be saved as a note."
)]
Add {
- #[structopt(help = "Name of the password entry")]
+ #[arg(help = "Name of the password entry")]
name: String,
- #[structopt(help = "Username for the password entry")]
+ #[arg(help = "Username for the password entry")]
user: Option<String>,
- #[structopt(
+ #[arg(
long,
help = "URI for the password entry",
- multiple = true,
number_of_values = 1
)]
uri: Vec<String>,
- #[structopt(long, help = "Folder for the password entry")]
+ #[arg(long, help = "Folder for the password entry")]
folder: Option<String>,
},
- #[structopt(
+ #[command(
about = "Generate a new password",
long_about = "Generate a new password\n\n\
If given a password entry name, also save the generated \
password to the database.",
visible_alias = "gen",
- group = structopt::clap::ArgGroup::with_name("password-type").args(&[
+ group = clap::ArgGroup::new("password-type").args(&[
"no-symbols",
"only-numbers",
"nonconfusables",
@@ -120,39 +140,38 @@ enum Opt {
])
)]
Generate {
- #[structopt(help = "Length of the password to generate")]
+ #[arg(help = "Length of the password to generate")]
len: usize,
- #[structopt(help = "Name of the password entry")]
+ #[arg(help = "Name of the password entry")]
name: Option<String>,
- #[structopt(help = "Username for the password entry")]
+ #[arg(help = "Username for the password entry")]
user: Option<String>,
- #[structopt(
+ #[arg(
long,
help = "URI for the password entry",
- multiple = true,
number_of_values = 1
)]
uri: Vec<String>,
- #[structopt(long, help = "Folder for the password entry")]
+ #[arg(long, help = "Folder for the password entry")]
folder: Option<String>,
- #[structopt(
+ #[arg(
long = "no-symbols",
help = "Generate a password with no special characters"
)]
no_symbols: bool,
- #[structopt(
+ #[arg(
long = "only-numbers",
help = "Generate a password consisting of only numbers"
)]
only_numbers: bool,
- #[structopt(
+ #[arg(
long,
help = "Generate a password without visually similar \
characters (useful for passwords intended to be \
written down)"
)]
nonconfusables: bool,
- #[structopt(
+ #[arg(
long,
help = "Generate a password of multiple dictionary \
words chosen from the EFF word list. The len \
@@ -162,7 +181,7 @@ enum Opt {
diceware: bool,
},
- #[structopt(
+ #[command(
about = "Modify an existing password",
long_about = "Modify an existing password\n\n\
This command will open a text editor with the existing \
@@ -173,50 +192,48 @@ enum Opt {
as a note."
)]
Edit {
- #[structopt(help = "Name or UUID of the password entry")]
+ #[arg(help = "Name or UUID of the password entry")]
name: String,
- #[structopt(help = "Username for the password entry")]
+ #[arg(help = "Username for the password entry")]
user: Option<String>,
- #[structopt(long, help = "Folder name to search in")]
+ #[arg(long, help = "Folder name to search in")]
folder: Option<String>,
},
- #[structopt(about = "Remove a given entry", visible_alias = "rm")]
+ #[command(about = "Remove a given entry", visible_alias = "rm")]
Remove {
- #[structopt(help = "Name or UUID of the password entry")]
+ #[arg(help = "Name or UUID of the password entry")]
name: String,
- #[structopt(help = "Username for the password entry")]
+ #[arg(help = "Username for the password entry")]
user: Option<String>,
- #[structopt(long, help = "Folder name to search in")]
+ #[arg(long, help = "Folder name to search in")]
folder: Option<String>,
},
- #[structopt(about = "View the password history for a given entry")]
+ #[command(about = "View the password history for a given entry")]
History {
- #[structopt(help = "Name or UUID of the password entry")]
+ #[arg(help = "Name or UUID of the password entry")]
name: String,
- #[structopt(help = "Username for the password entry")]
+ #[arg(help = "Username for the password entry")]
user: Option<String>,
- #[structopt(long, help = "Folder name to search in")]
+ #[arg(long, help = "Folder name to search in")]
folder: Option<String>,
},
- #[structopt(about = "Lock the password database")]
+ #[command(about = "Lock the password database")]
Lock,
- #[structopt(about = "Remove the local copy of the password database")]
+ #[command(about = "Remove the local copy of the password database")]
Purge,
- #[structopt(
- name = "stop-agent",
- about = "Terminate the background agent"
- )]
+ #[command(name = "stop-agent", about = "Terminate the background agent")]
StopAgent,
- #[structopt(
+
+ #[command(
name = "gen-completions",
about = "Generate completion script for the given shell"
)]
- GenCompletions { shell: String },
+ GenCompletions { shell: clap_complete::Shell },
}
impl Opt {
@@ -246,20 +263,20 @@ impl Opt {
}
}
-#[derive(Debug, structopt::StructOpt)]
+#[derive(Debug, clap::Parser)]
enum Config {
- #[structopt(about = "Show the values of all configuration settings")]
+ #[command(about = "Show the values of all configuration settings")]
Show,
- #[structopt(about = "Set a configuration option")]
+ #[command(about = "Set a configuration option")]
Set {
- #[structopt(help = "Configuration key to set")]
+ #[arg(help = "Configuration key to set")]
key: String,
- #[structopt(help = "Value to set the configuration option to")]
+ #[arg(help = "Value to set the configuration option to")]
value: String,
},
- #[structopt(about = "Reset a configuration option to its default")]
+ #[command(about = "Reset a configuration option to its default")]
Unset {
- #[structopt(help = "Configuration key to unset")]
+ #[arg(help = "Configuration key to unset")]
key: String,
},
}
@@ -275,15 +292,18 @@ impl Config {
}
}
-#[paw::main]
-fn main(opt: Opt) {
+fn main() {
+ let opt = Opt::parse();
+
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"),
)
.format(|buf, record| {
- if let Some((w, _)) = term_size::dimensions() {
+ if let Some((terminal_size::Width(w), _)) =
+ terminal_size::terminal_size()
+ {
let out = format!("{}: {}", record.level(), record.args());
- writeln!(buf, "{}", textwrap::fill(&out, w - 1))
+ writeln!(buf, "{}", textwrap::fill(&out, usize::from(w) - 1))
} else {
writeln!(buf, "{}: {}", record.level(), record.args())
}
@@ -303,14 +323,33 @@ fn main(opt: Opt) {
Opt::Sync => commands::sync(),
Opt::List { fields } => commands::list(fields),
Opt::Get {
- name,
+ needle,
user,
folder,
+ field,
full,
- } => commands::get(name, user.as_deref(), folder.as_deref(), *full),
- Opt::Code { name, user, folder } => {
- commands::code(name, user.as_deref(), folder.as_deref())
- }
+ raw,
+ clipboard,
+ } => commands::get(
+ needle,
+ user.as_deref(),
+ folder.as_deref(),
+ field.as_deref(),
+ *full,
+ *raw,
+ *clipboard,
+ ),
+ Opt::Code {
+ needle,
+ user,
+ folder,
+ clipboard,
+ } => commands::code(
+ needle,
+ user.as_deref(),
+ folder.as_deref(),
+ *clipboard,
+ ),
Opt::Add {
name,
user,
@@ -319,7 +358,7 @@ fn main(opt: Opt) {
} => commands::add(
name,
user.as_deref(),
- uri.iter()
+ &uri.iter()
// XXX not sure what the ui for specifying the match type
// should be
.map(|uri| (uri.clone(), None))
@@ -351,7 +390,7 @@ fn main(opt: Opt) {
commands::generate(
name.as_deref(),
user.as_deref(),
- uri.iter()
+ &uri.iter()
// XXX not sure what the ui for specifying the match type
// should be
.map(|uri| (uri.clone(), None))
@@ -373,25 +412,20 @@ fn main(opt: Opt) {
Opt::Lock => commands::lock(),
Opt::Purge => commands::purge(),
Opt::StopAgent => commands::stop_agent(),
- Opt::GenCompletions { shell } => gen_completions(shell),
+ Opt::GenCompletions { shell } => {
+ clap_complete::generate(
+ *shell,
+ &mut Opt::command(),
+ "rbw",
+ &mut std::io::stdout(),
+ );
+ Ok(())
+ }
}
.context(format!("rbw {}", opt.subcommand_name()));
if let Err(e) = res {
- eprintln!("{:#}", e);
+ eprintln!("{e:#}");
std::process::exit(1);
}
}
-
-fn gen_completions(shell: &str) -> anyhow::Result<()> {
- let shell = match shell {
- "bash" => structopt::clap::Shell::Bash,
- "zsh" => structopt::clap::Shell::Zsh,
- "fish" => structopt::clap::Shell::Fish,
- "powershell" => structopt::clap::Shell::PowerShell,
- "elvish" => structopt::clap::Shell::Elvish,
- _ => return Err(anyhow::anyhow!("unknown shell {}", shell)),
- };
- Opt::clap().gen_completions_to("rbw", shell, &mut std::io::stdout());
- Ok(())
-}