use core::fmt;
use std::{env, ffi::OsStr, path::Path};
use secrecy::SecretString;
use crate::prompt::{self, PromptResult};
#[cfg(windows)]
const WINDOWS_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
#[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.sort_by_key(|provider| !provider.installed());
providers
}
pub fn installed(self) -> bool {
installed(&self.read_command(None, ""))
}
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 => {
let service = service.unwrap_or(key);
format!("security add-generic-password -U -s {service} -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> {
let mut brokers = vec![Self::Ortie, Self::Pizauth, Self::Oama];
brokers.sort_by_key(|broker| !broker.installed());
brokers
}
pub fn installed(self) -> bool {
installed(&self.read_command(""))
}
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 installed(argv: &[String]) -> bool {
let Some(program) = argv.first() else {
return false;
};
let Some(paths) = env::var_os("PATH") else {
return false;
};
found_in(&paths, program)
}
fn found_in(paths: &OsStr, program: &str) -> bool {
env::split_paths(paths).any(|dir| found_at(&dir, program))
}
#[cfg(not(windows))]
fn found_at(dir: &Path, program: &str) -> bool {
dir.join(program).is_file()
}
#[cfg(windows)]
fn found_at(dir: &Path, program: &str) -> bool {
dir.join(program).is_file()
|| WINDOWS_EXTENSIONS
.iter()
.any(|extension| dir.join(format!("{program}{extension}")).is_file())
}
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(),
}
}
pub enum SecretChoice {
Command(Vec<String>),
Shell(String),
Raw(SecretString),
}
#[derive(Eq, PartialEq)]
enum Choice {
Keyring {
provider: KeyringProvider,
installed: bool,
},
Broker {
broker: TokenBroker,
installed: bool,
},
Custom,
Raw,
}
impl fmt::Display for Choice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Keyring {
provider,
installed,
} => write!(f, "{}", label(provider.name(), *installed)),
Self::Broker { broker, installed } => {
write!(f, "{}", label(broker.name(), *installed))
}
Self::Custom => f.write_str("Custom shell command"),
Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
}
}
}
fn label(name: &str, installed: bool) -> String {
if installed {
return name.to_owned();
}
format!("{name}, not found on PATH")
}
pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
let mut choices = keyring_choices();
choices.push(Choice::Custom);
choices.push(Choice::Raw);
prompt_choice(label, key_default, choices)
}
pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
let mut choices = keyring_choices();
if oauth {
choices.extend(
TokenBroker::available()
.into_iter()
.map(|broker| Choice::Broker {
installed: broker.installed(),
broker,
}),
);
}
choices.push(Choice::Custom);
choices.push(Choice::Raw);
prompt_choice(label, key_default, choices)
}
fn keyring_choices() -> Vec<Choice> {
KeyringProvider::available()
.into_iter()
.map(|provider| Choice::Keyring {
installed: provider.installed(),
provider,
})
.collect()
}
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 std::{fs, path::PathBuf};
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 keyring_write_command_names_a_security_service_without_a_namespace() {
assert_eq!(
KeyringProvider::Security.write_command(None, "acme"),
"security add-generic-password -U -s acme -a acme -w \"$(cat)\"",
);
assert_eq!(
KeyringProvider::Security.write_command(Some("ortie"), "acme"),
"security add-generic-password -U -s ortie -a acme -w \"$(cat)\"",
);
}
#[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());
}
}
#[test]
fn available_lists_the_installed_entries_first() {
let providers = KeyringProvider::available();
if let Some(missing) = providers.iter().position(|p| !p.installed()) {
assert!(providers[missing..].iter().all(|p| !p.installed()));
}
let brokers = TokenBroker::available();
if let Some(missing) = brokers.iter().position(|b| !b.installed()) {
assert!(brokers[missing..].iter().all(|b| !b.installed()));
}
}
#[test]
fn a_program_is_looked_up_in_every_path_entry() {
let dir = env::temp_dir().join("pimalaya-cli-keyring-path");
fs::create_dir_all(&dir).expect("create the PATH directory");
let program = "pimalaya-cli-test-provider";
fs::write(dir.join(program), "").expect("write the program");
let paths =
env::join_paths([PathBuf::from("/nonexistent"), dir.clone()]).expect("join the paths");
assert!(found_in(&paths, program));
assert!(!found_in(&paths, "pimalaya-cli-test-missing"));
let elsewhere = env::join_paths([PathBuf::from("/nonexistent")]).expect("join the paths");
assert!(!found_in(&elsewhere, program));
fs::remove_file(dir.join(program)).expect("remove the program");
}
#[test]
fn an_entry_that_is_not_there_says_so() {
assert_eq!(
label("pass (password store)", true),
"pass (password store)"
);
assert_eq!(
label("pass (password store)", false),
"pass (password store), not found on PATH",
);
}
#[test]
fn an_empty_command_is_not_installed() {
assert!(!installed(&[]));
}
}