use core::fmt;
use std::{
env,
ffi::OsStr,
io::{self, Write},
path::Path,
process::{Command, ExitStatus, Stdio},
};
use secrecy::{ExposeSecret, SecretSlice, 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 => 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)),
}
}
pub fn store(self, key: &str, secret: &SecretString) -> io::Result<()> {
let line = match self {
Self::Security => Some(security_line(key, secret)?),
_ => None,
};
let input = line.as_ref().unwrap_or(secret);
let argv = self.store_command(key);
let mut child = Command::new(&argv[0])
.args(&argv[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?;
let written = match child.stdin.take() {
Some(mut stdin) => stdin.write_all(input.expose_secret().as_bytes()),
None => Ok(()),
};
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(failure(&argv[0], &output.stderr, output.status));
}
written
}
fn store_command(self, key: &str) -> Vec<String> {
match self {
Self::SecretTool => argv(["secret-tool", "store", "--label", key, "account", key]),
Self::KwalletQuery => argv(["kwallet-query", "-w", key, "kdewallet"]),
Self::Security => argv(["security", "-i"]),
Self::Pass => argv(["pass", "insert", "-m", "-f", key]),
}
}
fn read_back(self, key: &str, secret: &SecretString) -> io::Result<()> {
let argv = self.read_command(None, key);
let output = Command::new(&argv[0])
.args(&argv[1..])
.stdin(Stdio::null())
.output()?;
let stdout = SecretSlice::from(output.stdout);
if !output.status.success() {
return Err(failure(&argv[0], &output.stderr, output.status));
}
let stored = stdout.expose_secret();
let stored = stored.strip_suffix(b"\n").unwrap_or(stored);
if stored != secret.expose_secret().as_bytes() {
return Err(io::Error::other("Entry read back does not match"));
}
Ok(())
}
}
#[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 secret_tool_lookup(attribute: &str, key: &str) -> Vec<String> {
argv(["secret-tool", "lookup", attribute, key])
}
fn path(service: Option<&str>, key: &str) -> String {
match service {
Some(service) => format!("{service}/{key}"),
None => key.to_owned(),
}
}
fn security_line(key: &str, secret: &SecretString) -> io::Result<SecretString> {
let secret = secret.expose_secret();
if key.contains(['\n', '\r']) || secret.contains(['\n', '\r']) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot store a key or a secret holding a line break",
));
}
let mut line = String::with_capacity(64 + 4 * key.len() + 2 * secret.len());
line.push_str("add-generic-password -U -s ");
push_quoted(&mut line, key);
line.push_str(" -a ");
push_quoted(&mut line, key);
line.push_str(" -w ");
push_quoted(&mut line, secret);
line.push('\n');
Ok(line.into())
}
fn push_quoted(line: &mut String, value: &str) {
line.push('"');
for c in value.chars() {
if matches!(c, '"' | '\\') {
line.push('\\');
}
line.push(c);
}
line.push('"');
}
fn failure(program: &str, stderr: &[u8], status: ExitStatus) -> io::Error {
let stderr = String::from_utf8_lossy(stderr);
let stderr = stderr.split_whitespace().collect::<Vec<_>>().join(" ");
if stderr.is_empty() {
return io::Error::other(format!("{program} failed, {status}"));
}
io::Error::other(stderr)
}
fn noun(label: &str) -> String {
let mut chars = label.chars();
match (chars.next(), chars.next()) {
(Some(first), Some(second)) if second.is_lowercase() => first
.to_lowercase()
.chain(label[first.len_utf8()..].chars())
.collect(),
_ => label.to_string(),
}
}
fn off_domain(endpoint: &str, domain: &str) -> bool {
let rest = match endpoint.split_once("://") {
Some((_, rest)) => rest,
None => endpoint,
};
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..end];
let authority = match authority.rsplit_once('@') {
Some((_, authority)) => authority,
None => authority,
};
let host = match authority.strip_prefix('[') {
Some(ipv6) => ipv6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
let host = host.trim_end_matches('.').to_lowercase();
let domain = domain.trim_end_matches('.').to_lowercase();
host != domain && !host.ends_with(&format!(".{domain}"))
}
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")
}
#[derive(Eq, PartialEq)]
enum Storage {
Keyring(KeyringProvider),
Custom,
Raw,
}
impl fmt::Display for Storage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Keyring(provider) => write!(f, "Store it in {}", provider.name()),
Self::Custom => f.write_str("Custom shell command printing it"),
Self::Raw => f.write_str("Raw in the configuration (plaintext, NOT recommended)"),
}
}
}
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)
}
pub fn prompt_password(
label: &str,
endpoints: &[&str],
domain: Option<&str>,
) -> PromptResult<SecretString> {
let noun = noun(label);
for endpoint in endpoints {
match domain {
Some(domain) if off_domain(endpoint, domain) => {
eprintln!("Your {noun} goes to {endpoint}, not on {domain}")
}
_ => eprintln!("Your {noun} goes to {endpoint}"),
}
}
let password = prompt::secret(format!("{label}:"))?;
Ok(password.into())
}
pub fn prompt_store(label: &str, key: &str, secret: &SecretString) -> PromptResult<SecretChoice> {
let mut providers: Vec<_> = KeyringProvider::available()
.into_iter()
.filter(|provider| provider.installed())
.collect();
loop {
let mut choices: Vec<_> = providers.iter().copied().map(Storage::Keyring).collect();
choices.push(Storage::Custom);
choices.push(Storage::Raw);
match prompt::item(format!("{label} storage:"), choices, None)? {
Storage::Keyring(provider) => {
let stored = provider
.store(key, secret)
.and_then(|()| provider.read_back(key, secret));
match stored {
Ok(()) => return Ok(SecretChoice::Command(provider.read_command(None, key))),
Err(err) => {
eprintln!("Cannot store {label} in {}: {err}", provider.name());
providers.retain(|other| *other != provider);
}
}
}
Storage::Custom => {
let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
return Ok(SecretChoice::Shell(command));
}
Storage::Raw => return Ok(SecretChoice::Raw(secret.clone())),
}
}
}
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: KeyringProvider::SecretTool,
..
} => {
let attribute = prompt::text(
format!("{label} keyring attribute:"),
Some("account".to_owned()),
)?;
let key = prompt::text(
format!("{label} keyring entry:"),
Some(key_default.to_owned()),
)?;
let attribute = match attribute.trim() {
"" => "account",
attribute => attribute,
};
Ok(SecretChoice::Command(secret_tool_lookup(attribute, &key)))
}
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 secret_tool_lookup_reads_the_named_attribute() {
assert_eq!(
secret_tool_lookup("Title", "disroot"),
["secret-tool", "lookup", "Title", "disroot"],
);
assert_eq!(
KeyringProvider::SecretTool.read_command(None, "disroot"),
secret_tool_lookup("account", "disroot"),
);
}
#[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(&[]));
}
#[test]
fn a_store_command_writes_the_entry_its_read_command_reads() {
let key = "acme-imap";
assert_eq!(
KeyringProvider::SecretTool.store_command(key),
[
"secret-tool",
"store",
"--label",
"acme-imap",
"account",
"acme-imap"
],
);
assert_eq!(
KeyringProvider::KwalletQuery.store_command(key),
["kwallet-query", "-w", "acme-imap", "kdewallet"],
);
assert_eq!(
KeyringProvider::Pass.store_command(key),
["pass", "insert", "-m", "-f", "acme-imap"],
);
assert_eq!(
KeyringProvider::Security.store_command(key),
["security", "-i"]
);
}
#[test]
fn a_keychain_line_quotes_and_escapes_its_values() {
let line = security_line("acme \"imap\"", &SecretString::from(r#"a"b\c d"#))
.expect("build the line");
assert_eq!(
line.expose_secret(),
"add-generic-password -U -s \"acme \\\"imap\\\"\" -a \"acme \\\"imap\\\"\" \
-w \"a\\\"b\\\\c d\"\n",
);
}
#[test]
fn a_keychain_line_refuses_a_line_break() {
let secret = SecretString::from("a\nadd-generic-password");
let err = security_line("acme", &secret).expect_err("refuse the secret");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let secret = SecretString::from("abc");
let err = security_line("acme\r", &secret).expect_err("refuse the key");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn an_endpoint_is_on_its_domain_and_subdomains_only() {
let domain = "example.org";
assert!(!off_domain("imaps://imap.example.org:993", domain));
assert!(!off_domain("https://example.org/jmap", domain));
assert!(!off_domain("imaps://IMAP.Example.ORG:993", domain));
assert!(!off_domain("smtp://mail.example.org.:587", "Example.org"));
assert!(!off_domain("imap.example.org:143", domain));
assert!(off_domain("imaps://imap.example.net:993", domain));
assert!(off_domain("https://example.org.example.net/", domain));
assert!(off_domain("https://notexample.org", domain));
assert!(off_domain(
"imaps://imap.example.org@example.net:993",
domain
));
assert!(off_domain("https://[2001:db8::1]:443/example.org", domain));
assert!(off_domain("https://example.net?example.org", domain));
}
#[test]
fn a_label_reads_as_a_noun_its_initialisms_kept() {
assert_eq!(noun("Password"), "password");
assert_eq!(noun("SMTP password"), "SMTP password");
assert_eq!(noun("API token"), "API token");
assert_eq!(noun("P"), "P");
}
}