#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
use core::fmt;
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
use secrecy::SecretString;
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
use crate::prompt::{self, PromptResult};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KeyringProvider {
SecretTool,
KwalletQuery,
Security,
Pass,
}
impl KeyringProvider {
pub fn available() -> Vec<Self> {
let mut providers = Vec::new();
if cfg!(target_os = "linux") {
providers.push(Self::SecretTool);
providers.push(Self::KwalletQuery);
}
if cfg!(target_os = "macos") {
providers.push(Self::Security);
}
if cfg!(unix) {
providers.push(Self::Pass);
}
providers
}
pub fn name(self) -> &'static str {
match self {
Self::SecretTool => "secret-tool (GNOME Keyring / Secret Service)",
Self::KwalletQuery => "kwallet-query (KDE Wallet)",
Self::Security => "security (macOS Keychain)",
Self::Pass => "pass (password store)",
}
}
pub fn read_command(self, service: Option<&str>, key: &str) -> Vec<String> {
match self {
Self::SecretTool => match service {
Some(service) => {
argv(["secret-tool", "lookup", "service", service, "account", key])
}
None => argv(["secret-tool", "lookup", "account", key]),
},
Self::KwalletQuery => {
let entry = path(service, key);
argv(["kwallet-query", "-r", &entry, "kdewallet"])
}
Self::Security => match service {
Some(service) => argv([
"security",
"find-generic-password",
"-s",
service,
"-a",
key,
"-w",
]),
None => argv(["security", "find-generic-password", "-a", key, "-w"]),
},
Self::Pass => {
let entry = path(service, key);
argv(["pass", "show", &entry])
}
}
}
pub fn write_command(self, service: Option<&str>, key: &str) -> String {
match self {
Self::SecretTool => match service {
Some(service) => format!(
"secret-tool store --label {service}/{key} service {service} account {key}"
),
None => format!("secret-tool store --label {key} account {key}"),
},
Self::KwalletQuery => format!("kwallet-query -w {} kdewallet", path(service, key)),
Self::Security => match service {
Some(service) => {
format!("security add-generic-password -U -s {service} -a {key} -w \"$(cat)\"")
}
None => format!("security add-generic-password -U -a {key} -w \"$(cat)\""),
},
Self::Pass => format!("pass insert -m -f {}", path(service, key)),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TokenBroker {
Ortie,
Pizauth,
Oama,
}
impl TokenBroker {
pub fn available() -> Vec<Self> {
vec![Self::Ortie, Self::Pizauth, Self::Oama]
}
pub fn name(self) -> &'static str {
match self {
Self::Ortie => "ortie (Pimalaya OAuth 2.0 token broker)",
Self::Pizauth => "pizauth (OAuth 2.0 token daemon)",
Self::Oama => "oama (OAuth Anywhere Mail Agent)",
}
}
pub fn read_command(self, account: &str) -> Vec<String> {
match self {
Self::Ortie => argv(["ortie", "token", "show", "-a", account]),
Self::Pizauth => argv(["pizauth", "show", account]),
Self::Oama => argv(["oama", "access", account]),
}
}
}
fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
parts.iter().map(|part| part.to_string()).collect()
}
fn path(service: Option<&str>, key: &str) -> String {
match service {
Some(service) => format!("{service}/{key}"),
None => key.to_owned(),
}
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
pub enum SecretChoice {
Command(Vec<String>),
Shell(String),
Raw(SecretString),
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
enum Choice {
Keyring(KeyringProvider),
Broker(TokenBroker),
Custom,
Raw,
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
impl PartialEq for Choice {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Keyring(a), Self::Keyring(b)) => a == b,
(Self::Broker(a), Self::Broker(b)) => a == b,
(Self::Custom, Self::Custom) | (Self::Raw, Self::Raw) => true,
_ => false,
}
}
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
impl Eq for Choice {}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
impl fmt::Display for Choice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Keyring(provider) => f.write_str(provider.name()),
Self::Broker(broker) => f.write_str(broker.name()),
Self::Custom => f.write_str("Custom shell command"),
Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
}
}
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
let mut choices: Vec<Choice> = KeyringProvider::available()
.into_iter()
.map(Choice::Keyring)
.collect();
choices.push(Choice::Custom);
choices.push(Choice::Raw);
prompt_choice(label, key_default, choices)
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
let mut choices: Vec<Choice> = KeyringProvider::available()
.into_iter()
.map(Choice::Keyring)
.collect();
if oauth {
choices.extend(TokenBroker::available().into_iter().map(Choice::Broker));
}
choices.push(Choice::Custom);
choices.push(Choice::Raw);
prompt_choice(label, key_default, choices)
}
#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
fn prompt_choice(
label: &str,
key_default: &str,
choices: Vec<Choice>,
) -> PromptResult<SecretChoice> {
match prompt::item(format!("{label} strategy:"), choices, None)? {
Choice::Keyring(provider) => {
let key = prompt::text(
format!("{label} keyring entry:"),
Some(key_default.to_owned()),
)?;
Ok(SecretChoice::Command(provider.read_command(None, &key)))
}
Choice::Broker(broker) => {
let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
Ok(SecretChoice::Command(broker.read_command(&account)))
}
Choice::Custom => {
let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
Ok(SecretChoice::Shell(command))
}
Choice::Raw => {
let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
Ok(SecretChoice::Raw(secret))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
let entry = "pimalaya/posteo";
assert_eq!(
KeyringProvider::Pass.read_command(None, entry),
["pass", "show", "pimalaya/posteo"],
);
assert_eq!(
KeyringProvider::SecretTool.read_command(None, entry),
["secret-tool", "lookup", "account", "pimalaya/posteo"],
);
assert_eq!(
KeyringProvider::Security.read_command(None, entry),
[
"security",
"find-generic-password",
"-a",
"pimalaya/posteo",
"-w"
],
);
assert_eq!(
KeyringProvider::KwalletQuery.read_command(None, entry),
["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
);
}
#[test]
fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
let (service, account) = (Some("ortie"), "acme");
assert_eq!(
KeyringProvider::Pass.read_command(service, account),
["pass", "show", "ortie/acme"],
);
assert_eq!(
KeyringProvider::SecretTool.read_command(service, account),
[
"secret-tool",
"lookup",
"service",
"ortie",
"account",
"acme"
],
);
assert_eq!(
KeyringProvider::Security.read_command(service, account),
[
"security",
"find-generic-password",
"-s",
"ortie",
"-a",
"acme",
"-w"
],
);
}
#[test]
fn broker_read_command_targets_the_account_per_broker() {
assert_eq!(
TokenBroker::Ortie.read_command("acme"),
["ortie", "token", "show", "-a", "acme"],
);
assert_eq!(
TokenBroker::Pizauth.read_command("acme"),
["pizauth", "show", "acme"]
);
assert_eq!(
TokenBroker::Oama.read_command("me@acme.test"),
["oama", "access", "me@acme.test"],
);
}
#[test]
fn available_lists_are_non_empty() {
assert!(!TokenBroker::available().is_empty());
if cfg!(unix) {
assert!(!KeyringProvider::available().is_empty());
}
}
}