use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;
use crate::account::{Account, Auth, PasswordFrom};
use crate::oauth::{self, Tokens};
use crate::text;
const SERVICE: &str = "tuimail";
const COMMAND_TIMEOUT: Duration = Duration::from_secs(60);
const INTERACTIVE_TIMEOUT: Duration = Duration::from_secs(300);
const MAX_OUTPUT: u64 = 64 * 1024;
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(…)")
}
}
pub fn init_keychain() -> bool {
#[cfg(target_os = "macos")]
let store = apple_native_keyring_store::keychain::Store::new()
.map(|s| s as Arc<keyring_core::CredentialStore>);
#[cfg(windows)]
let store =
windows_native_keyring_store::Store::new().map(|s| s as Arc<keyring_core::CredentialStore>);
#[cfg(all(unix, not(target_os = "macos")))]
let store = zbus_secret_service_keyring_store::Store::new()
.map(|s| s as Arc<keyring_core::CredentialStore>);
match store {
Ok(store) => {
keyring_core::set_default_store(store);
true
}
Err(_) => false,
}
}
fn entry(id: &str) -> Result<keyring_core::Entry> {
keyring_core::Entry::new(SERVICE, id).map_err(|e| anyhow!("the keychain: {e}"))
}
pub fn token_key(id: &str) -> String {
format!("{id}#refresh-token")
}
pub async fn keychain_get(id: &str) -> Result<Option<Secret>> {
let id = id.to_string();
tokio::task::spawn_blocking(move || match entry(&id)?.get_password() {
Ok(p) => Ok(Some(Secret(p))),
Err(keyring_core::Error::NoEntry) => Ok(None),
Err(e) => Err(anyhow!("cannot read the keychain: {e}")),
})
.await?
}
pub async fn keychain_set(id: &str, secret: &Secret) -> Result<()> {
let (id, secret) = (id.to_string(), secret.clone());
tokio::task::spawn_blocking(move || {
entry(&id)?
.set_password(secret.expose())
.map_err(|e| anyhow!("cannot save the password in the keychain: {e}"))
})
.await?
}
pub async fn keychain_delete(id: &str) -> Result<()> {
let id = id.to_string();
tokio::task::spawn_blocking(move || match entry(&id)?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(anyhow!("cannot remove the password from the keychain: {e}")),
})
.await?
}
pub async fn run_command(argv: &[String], interactive: bool) -> Result<Secret> {
let program = argv.first().context("password_command is empty")?;
let mut command = tokio::process::Command::new(program);
command
.args(&argv[1..])
.stdout(Stdio::piped())
.kill_on_drop(true);
if let Some(home) = dirs::home_dir() {
command.current_dir(home);
}
if interactive {
command.stdin(Stdio::inherit()).stderr(Stdio::inherit());
} else {
command.stdin(Stdio::null()).stderr(Stdio::null());
}
let shown = text::one_line(program, 80);
let mut child = command
.spawn()
.with_context(|| format!("cannot run {shown}"))?;
let mut stdout = child.stdout.take().context("no output")?;
let timeout = if interactive {
INTERACTIVE_TIMEOUT
} else {
COMMAND_TIMEOUT
};
let run = async {
let mut out = Vec::new();
(&mut stdout)
.take(MAX_OUTPUT + 1)
.read_to_end(&mut out)
.await?;
let status = child.wait().await?;
anyhow::Ok((out, status))
};
let (out, status) = tokio::time::timeout(timeout, run)
.await
.map_err(|_| anyhow!("{shown} took too long"))??;
if !status.success() {
bail!("{shown} failed ({status})");
}
if out.len() as u64 > MAX_OUTPUT {
bail!("{shown} printed too much to be a password");
}
let text =
String::from_utf8(out).map_err(|_| anyhow!("{shown} printed something that isn't text"))?;
let line = text.lines().next().unwrap_or("").trim_end_matches('\r');
if line.is_empty() {
bail!("{shown} printed no password");
}
Ok(Secret(line.to_string()))
}
#[derive(Clone)]
pub struct Credentials {
account: Arc<Account>,
id: String,
secret: Arc<Mutex<Option<Secret>>>,
keychain: bool,
keychain_refused: Arc<std::sync::atomic::AtomicBool>,
tokens: Arc<std::sync::Mutex<(u64, Option<Tokens>)>>,
refreshing: Arc<Mutex<()>>,
}
#[derive(Debug)]
pub struct NeedPassword;
impl std::fmt::Display for NeedPassword {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "the password is needed")
}
}
impl std::error::Error for NeedPassword {}
impl Credentials {
pub fn new(account: Arc<Account>, keychain: bool) -> Self {
let id = account.id();
Self {
account,
id,
secret: Arc::new(Mutex::new(None)),
keychain,
keychain_refused: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tokens: Arc::new(std::sync::Mutex::new((0, None))),
refreshing: Arc::new(Mutex::new(())),
}
}
pub fn keychain_key(&self) -> String {
if self.signs_in() {
token_key(&self.id)
} else {
self.id.clone()
}
}
fn tokens(&self) -> std::sync::MutexGuard<'_, (u64, Option<Tokens>)> {
self.tokens.lock().unwrap_or_else(|e| e.into_inner())
}
fn is_refused(&self) -> bool {
self.keychain_refused
.load(std::sync::atomic::Ordering::SeqCst)
}
fn set_refused(&self, refused: bool) {
self.keychain_refused
.store(refused, std::sync::atomic::Ordering::SeqCst);
}
fn uses_command(&self) -> bool {
self.account.password == PasswordFrom::Command
|| (self.account.auth == Auth::Oauth2 && !self.signs_in())
}
pub fn signs_in(&self) -> bool {
oauth::built_in(&self.account)
}
pub fn uses_keychain(&self) -> bool {
self.keychain && self.account.password == PasswordFrom::Keychain && !self.uses_command()
}
pub async fn get(&self) -> Result<Secret> {
if self.signs_in() {
return self.access_token().await;
}
if self.uses_command() {
if self.is_refused() {
return Err(anyhow!(NeedPassword));
}
return run_command(&self.account.password_command, false).await;
}
let mut kept = self.secret.lock().await;
if let Some(secret) = kept.as_ref() {
return Ok(secret.clone());
}
if self.uses_keychain()
&& !self.is_refused()
&& let Some(secret) = keychain_get(&self.id).await?
{
*kept = Some(secret.clone());
return Ok(secret);
}
Err(anyhow!(NeedPassword))
}
async fn access_token(&self) -> Result<Secret> {
if self.is_refused() {
return Err(anyhow!(NeedPassword));
}
let _one = self.refreshing.lock().await;
if self.is_refused() {
return Err(anyhow!(NeedPassword));
}
let (signed_in, refresh) = {
let kept = self.tokens();
if let Some(tokens) = kept.1.as_ref().filter(|t| t.fresh()) {
return Ok(tokens.access.clone());
}
(kept.0, kept.1.as_ref().and_then(|t| t.refresh.clone()))
};
let refresh = match refresh {
Some(refresh) => refresh,
None if self.uses_keychain() => keychain_get(&self.keychain_key())
.await?
.ok_or_else(|| anyhow!(NeedPassword))?,
None => return Err(anyhow!(NeedPassword)),
};
let client = oauth::client_for(&self.account)
.ok_or_else(|| anyhow!("this account can't sign in: see OAuth2 in tuimail's README"))?;
let result = oauth::refresh(&client, &refresh).await;
let mut kept = self.tokens();
if kept.0 != signed_in {
return (kept.1.as_ref())
.map(|t| t.access.clone())
.ok_or_else(|| anyhow!(NeedPassword));
}
match result {
Ok(mut tokens) => {
if tokens.refresh.is_none() {
tokens.refresh = Some(refresh);
}
let access = tokens.access.clone();
kept.1 = Some(tokens);
Ok(access)
}
Err(e) if e.downcast_ref::<oauth::SignInAgain>().is_some() => {
kept.1 = None;
self.set_refused(true);
Err(anyhow!(NeedPassword))
}
Err(e) => Err(e),
}
}
pub fn set_tokens(&self, tokens: Tokens) {
let mut kept = self.tokens();
kept.0 += 1;
kept.1 = Some(tokens);
self.set_refused(false);
}
pub async fn set(&self, secret: Secret) {
*self.secret.lock().await = Some(secret);
self.set_refused(false);
}
pub fn retry(&self) {
if self.uses_command() || self.signs_in() {
self.set_refused(false);
}
}
pub async fn refused(&self, used: &Secret) {
if self.signs_in() {
let mut kept = self.tokens();
if kept.1.as_ref().is_none_or(|t| t.access == *used) {
kept.1 = None;
self.set_refused(true);
}
return;
}
let mut kept = self.secret.lock().await;
if kept.as_ref().is_none_or(|s| s == used) {
*kept = None;
self.set_refused(true);
}
}
pub async fn remember(&self, used: &Secret) -> Result<()> {
if !self.uses_keychain() {
return Ok(());
}
let kept = if self.signs_in() {
(self.tokens().1.as_ref())
.filter(|t| t.access == *used)
.and_then(|t| t.refresh.clone())
} else {
self.secret.lock().await.clone().filter(|s| s == used)
};
let Some(secret) = kept else {
return Ok(());
};
let key = self.keychain_key();
if keychain_get(&key).await.ok().flatten().as_ref() == Some(&secret) {
return Ok(());
}
keychain_set(&key, &secret).await
}
pub fn account(&self) -> &Account {
&self.account
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_secret_never_shows_in_debug_output() {
let secret = Secret::new("hunter2");
assert!(!format!("{secret:?}").contains("hunter2"));
}
#[cfg(unix)]
#[tokio::test]
async fn a_command_gives_its_first_line_and_must_succeed() {
let sh = |script: &str| vec!["/bin/sh".to_string(), "-c".into(), script.into()];
let secret = run_command(&sh("printf 'pa ss\\nmore\\n'"), false)
.await
.unwrap();
assert_eq!(secret.expose(), "pa ss");
assert!(run_command(&sh("exit 3"), false).await.is_err());
assert!(
run_command(&sh("printf ''"), false).await.is_err(),
"nothing printed"
);
assert!(
run_command(&sh("head -c 100000 /dev/zero"), false)
.await
.is_err(),
"too much"
);
assert!(
run_command(&["/nonexistent/x".into()], false)
.await
.is_err()
);
}
#[tokio::test]
async fn signing_in_gives_its_access_token_until_the_server_refuses_it() {
let account = Account {
email: "me@outlook.com".into(),
host: "outlook.office365.com".into(),
auth: Auth::Oauth2,
oauth_client_id: "my-app".into(),
..Account::default()
};
let creds = Credentials::new(Arc::new(account), false);
assert!(creds.signs_in());
let need = |r: Result<Secret>| r.unwrap_err().downcast_ref::<NeedPassword>().is_some();
assert!(need(creds.get().await), "not signed in yet");
let tokens = |access: &str| Tokens {
access: Secret::new(access),
refresh: Some(Secret::new("rt")),
renew_at: std::time::Instant::now() + Duration::from_secs(600),
};
creds.set_tokens(tokens("at"));
let used = creds.get().await.unwrap();
assert_eq!(used.expose(), "at");
creds.set_tokens(tokens("at-2"));
creds.refused(&used).await;
assert_eq!(creds.get().await.unwrap().expose(), "at-2");
creds.refused(&Secret::new("at-2")).await;
assert!(need(creds.get().await));
creds.retry();
assert!(need(creds.get().await), "nothing to refresh with");
assert_eq!(creds.keychain_key(), token_key(&creds.id));
}
#[cfg(unix)]
#[tokio::test]
async fn command_accounts_run_their_command_for_each_login() {
let account = Account {
email: "me@example.org".into(),
auth: Auth::Oauth2,
password_command: vec!["/bin/echo".into(), "token-1".into()],
..Account::default()
};
let creds = Credentials::new(Arc::new(account), false);
let used = creds.get().await.unwrap();
assert_eq!(used.expose(), "token-1");
creds.refused(&used).await;
assert!(
creds
.get()
.await
.unwrap_err()
.downcast_ref::<NeedPassword>()
.is_some()
);
creds.retry();
assert_eq!(creds.get().await.unwrap().expose(), "token-1");
let ask = Account {
email: "me@example.org".into(),
password: PasswordFrom::Ask,
..Account::default()
};
let creds = Credentials::new(Arc::new(ask), false);
assert!(
creds
.get()
.await
.unwrap_err()
.downcast_ref::<NeedPassword>()
.is_some()
);
creds.set(Secret::new("typed")).await;
let used = creds.get().await.unwrap();
assert_eq!(used.expose(), "typed");
creds.set(Secret::new("typed again")).await;
creds.refused(&used).await;
assert_eq!(creds.get().await.unwrap().expose(), "typed again");
creds.refused(&Secret::new("typed again")).await;
assert!(creds.get().await.is_err());
}
}