1#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
18use core::fmt;
19
20#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
21use secrecy::SecretString;
22
23#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
24use crate::prompt::{self, PromptResult};
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum KeyringProvider {
29 SecretTool,
31 KwalletQuery,
33 Security,
35 Pass,
37}
38
39impl KeyringProvider {
40 pub fn available() -> Vec<Self> {
44 let mut providers = Vec::new();
45
46 if cfg!(target_os = "linux") {
47 providers.push(Self::SecretTool);
48 providers.push(Self::KwalletQuery);
49 }
50
51 if cfg!(target_os = "macos") {
52 providers.push(Self::Security);
53 }
54
55 if cfg!(unix) {
56 providers.push(Self::Pass);
57 }
58
59 providers
60 }
61
62 pub fn name(self) -> &'static str {
64 match self {
65 Self::SecretTool => "secret-tool (GNOME Keyring / Secret Service)",
66 Self::KwalletQuery => "kwallet-query (KDE Wallet)",
67 Self::Security => "security (macOS Keychain)",
68 Self::Pass => "pass (password store)",
69 }
70 }
71
72 pub fn read_command(self, service: Option<&str>, key: &str) -> Vec<String> {
82 match self {
83 Self::SecretTool => match service {
84 Some(service) => {
85 argv(["secret-tool", "lookup", "service", service, "account", key])
86 }
87 None => argv(["secret-tool", "lookup", "account", key]),
88 },
89 Self::KwalletQuery => {
90 let entry = path(service, key);
91 argv(["kwallet-query", "-r", &entry, "kdewallet"])
92 }
93 Self::Security => match service {
94 Some(service) => argv([
95 "security",
96 "find-generic-password",
97 "-s",
98 service,
99 "-a",
100 key,
101 "-w",
102 ]),
103 None => argv(["security", "find-generic-password", "-a", key, "-w"]),
104 },
105 Self::Pass => {
106 let entry = path(service, key);
107 argv(["pass", "show", &entry])
108 }
109 }
110 }
111
112 pub fn write_command(self, service: Option<&str>, key: &str) -> String {
118 match self {
119 Self::SecretTool => match service {
120 Some(service) => format!(
121 "secret-tool store --label {service}/{key} service {service} account {key}"
122 ),
123 None => format!("secret-tool store --label {key} account {key}"),
124 },
125 Self::KwalletQuery => format!("kwallet-query -w {} kdewallet", path(service, key)),
126 Self::Security => match service {
129 Some(service) => {
130 format!("security add-generic-password -U -s {service} -a {key} -w \"$(cat)\"")
131 }
132 None => format!("security add-generic-password -U -a {key} -w \"$(cat)\""),
133 },
134 Self::Pass => format!("pass insert -m -f {}", path(service, key)),
135 }
136 }
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub enum TokenBroker {
147 Ortie,
149 Pizauth,
151 Oama,
153}
154
155impl TokenBroker {
156 pub fn available() -> Vec<Self> {
159 vec![Self::Ortie, Self::Pizauth, Self::Oama]
160 }
161
162 pub fn name(self) -> &'static str {
164 match self {
165 Self::Ortie => "ortie (Pimalaya OAuth 2.0 token broker)",
166 Self::Pizauth => "pizauth (OAuth 2.0 token daemon)",
167 Self::Oama => "oama (OAuth Anywhere Mail Agent)",
168 }
169 }
170
171 pub fn read_command(self, account: &str) -> Vec<String> {
180 match self {
181 Self::Ortie => argv(["ortie", "token", "show", "-a", account]),
182 Self::Pizauth => argv(["pizauth", "show", account]),
183 Self::Oama => argv(["oama", "access", account]),
184 }
185 }
186}
187
188fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
190 parts.iter().map(|part| part.to_string()).collect()
191}
192
193fn path(service: Option<&str>, key: &str) -> String {
196 match service {
197 Some(service) => format!("{service}/{key}"),
198 None => key.to_owned(),
199 }
200}
201
202#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
209pub enum SecretChoice {
210 Command(Vec<String>),
213 Shell(String),
216 Raw(SecretString),
218}
219
220#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
223enum Choice {
224 Keyring(KeyringProvider),
225 Broker(TokenBroker),
226 Custom,
227 Raw,
228}
229
230#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
231impl PartialEq for Choice {
232 fn eq(&self, other: &Self) -> bool {
233 match (self, other) {
234 (Self::Keyring(a), Self::Keyring(b)) => a == b,
235 (Self::Broker(a), Self::Broker(b)) => a == b,
236 (Self::Custom, Self::Custom) | (Self::Raw, Self::Raw) => true,
237 _ => false,
238 }
239 }
240}
241
242#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
243impl Eq for Choice {}
244
245#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
246impl fmt::Display for Choice {
247 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248 match self {
249 Self::Keyring(provider) => f.write_str(provider.name()),
250 Self::Broker(broker) => f.write_str(broker.name()),
251 Self::Custom => f.write_str("Custom shell command"),
252 Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
253 }
254 }
255}
256
257#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
261pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
262 let mut choices: Vec<Choice> = KeyringProvider::available()
263 .into_iter()
264 .map(Choice::Keyring)
265 .collect();
266 choices.push(Choice::Custom);
267 choices.push(Choice::Raw);
268
269 prompt_choice(label, key_default, choices)
270}
271
272#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
280pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
281 let mut choices: Vec<Choice> = KeyringProvider::available()
282 .into_iter()
283 .map(Choice::Keyring)
284 .collect();
285 if oauth {
286 choices.extend(TokenBroker::available().into_iter().map(Choice::Broker));
287 }
288 choices.push(Choice::Custom);
289 choices.push(Choice::Raw);
290
291 prompt_choice(label, key_default, choices)
292}
293
294#[cfg(any(feature = "imap", feature = "smtp", feature = "jmap"))]
303fn prompt_choice(
304 label: &str,
305 key_default: &str,
306 choices: Vec<Choice>,
307) -> PromptResult<SecretChoice> {
308 match prompt::item(format!("{label} strategy:"), choices, None)? {
309 Choice::Keyring(provider) => {
310 let key = prompt::text(
311 format!("{label} keyring entry:"),
312 Some(key_default.to_owned()),
313 )?;
314
315 Ok(SecretChoice::Command(provider.read_command(None, &key)))
316 }
317 Choice::Broker(broker) => {
318 let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
319
320 Ok(SecretChoice::Command(broker.read_command(&account)))
321 }
322 Choice::Custom => {
323 let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
324 Ok(SecretChoice::Shell(command))
325 }
326 Choice::Raw => {
327 let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
328 Ok(SecretChoice::Raw(secret))
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
339 let entry = "pimalaya/posteo";
340 assert_eq!(
341 KeyringProvider::Pass.read_command(None, entry),
342 ["pass", "show", "pimalaya/posteo"],
343 );
344 assert_eq!(
345 KeyringProvider::SecretTool.read_command(None, entry),
346 ["secret-tool", "lookup", "account", "pimalaya/posteo"],
347 );
348 assert_eq!(
349 KeyringProvider::Security.read_command(None, entry),
350 [
351 "security",
352 "find-generic-password",
353 "-a",
354 "pimalaya/posteo",
355 "-w"
356 ],
357 );
358 assert_eq!(
359 KeyringProvider::KwalletQuery.read_command(None, entry),
360 ["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
361 );
362 }
363
364 #[test]
365 fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
366 let (service, account) = (Some("ortie"), "acme");
367 assert_eq!(
368 KeyringProvider::Pass.read_command(service, account),
369 ["pass", "show", "ortie/acme"],
370 );
371 assert_eq!(
372 KeyringProvider::SecretTool.read_command(service, account),
373 [
374 "secret-tool",
375 "lookup",
376 "service",
377 "ortie",
378 "account",
379 "acme"
380 ],
381 );
382 assert_eq!(
383 KeyringProvider::Security.read_command(service, account),
384 [
385 "security",
386 "find-generic-password",
387 "-s",
388 "ortie",
389 "-a",
390 "acme",
391 "-w"
392 ],
393 );
394 }
395
396 #[test]
397 fn broker_read_command_targets_the_account_per_broker() {
398 assert_eq!(
399 TokenBroker::Ortie.read_command("acme"),
400 ["ortie", "token", "show", "-a", "acme"],
401 );
402 assert_eq!(
403 TokenBroker::Pizauth.read_command("acme"),
404 ["pizauth", "show", "acme"]
405 );
406 assert_eq!(
407 TokenBroker::Oama.read_command("me@acme.test"),
408 ["oama", "access", "me@acme.test"],
409 );
410 }
411
412 #[test]
413 fn available_lists_are_non_empty() {
414 assert!(!TokenBroker::available().is_empty());
415 if cfg!(unix) {
416 assert!(!KeyringProvider::available().is_empty());
417 }
418 }
419}