pimalaya_cli/wizard/keyring.rs
1//! OS-aware credential-provider picker shared by the account wizards.
2//!
3//! A secret is read from a well-known credential CLI, from a custom
4//! shell command, or stored raw in the configuration. Two flavours share
5//! the same machinery:
6//!
7//! - a **password** is read from an OS keyring ([`KeyringProvider`]);
8//! - an **OAuth 2.0 access token** is read from a token broker
9//! ([`TokenBroker`]) that refreshes it on every read.
10//!
11//! A known provider or broker yields an argv command (no shell), so the
12//! config serializes it as a TOML array; only a custom command falls
13//! back to a shell string. The picker never *writes* the secret: it just
14//! records the read command, leaving the value for the user to store
15//! under the chosen entry beforehand.
16//!
17//! Both lists are ordered by what the running system can actually do:
18//! the entries whose CLI is found on the `PATH` lead, the rest follow
19//! and say so. Nothing is hidden, since a provider missing today is one
20//! package install away and the configuration written for it is correct
21//! either way.
22
23use core::fmt;
24use std::{env, ffi::OsStr, path::Path};
25
26use secrecy::SecretString;
27
28use crate::prompt::{self, PromptResult};
29
30/// Extensions Windows appends to a bare program name, tried after the
31/// name itself. The `PATHEXT` list in practice, minus the entries no
32/// credential CLI ships as.
33#[cfg(windows)]
34const WINDOWS_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
35
36/// A well-known credential-provider CLI a password can be read from.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum KeyringProvider {
39 /// `secret-tool`, over the Secret Service (GNOME Keyring).
40 SecretTool,
41 /// `kwallet-query`, over the KDE Wallet.
42 KwalletQuery,
43 /// `security`, over the macOS Keychain.
44 Security,
45 /// `pass`, the standard unix password manager.
46 Pass,
47}
48
49impl KeyringProvider {
50 /// The providers relevant on the running OS, the ones actually
51 /// installed first and the rest in their wake, each group keeping
52 /// the native-first order. Empty on platforms without a known
53 /// stdin-friendly provider (Windows), where the picker goes straight
54 /// to a custom command.
55 pub fn available() -> Vec<Self> {
56 let mut providers = Vec::new();
57
58 if cfg!(target_os = "linux") {
59 providers.push(Self::SecretTool);
60 providers.push(Self::KwalletQuery);
61 }
62
63 if cfg!(target_os = "macos") {
64 providers.push(Self::Security);
65 }
66
67 if cfg!(unix) {
68 providers.push(Self::Pass);
69 }
70
71 // NOTE: a stable sort, so the native-first order above survives
72 // inside the installed and the missing group alike.
73 providers.sort_by_key(|provider| !provider.installed());
74
75 providers
76 }
77
78 /// Whether this provider's CLI is found on the `PATH`, which is what
79 /// leads the pick list: an installed provider is one a secret can be
80 /// stored in today, while the rest are a package install away.
81 ///
82 /// The provider names its own program, being the first element of
83 /// its read command, so no table of binary names is duplicated here.
84 pub fn installed(self) -> bool {
85 installed(&self.read_command(None, ""))
86 }
87
88 /// Display name of the provider, for the pick-list labels.
89 pub fn name(self) -> &'static str {
90 match self {
91 Self::SecretTool => "secret-tool (GNOME Keyring / Secret Service)",
92 Self::KwalletQuery => "kwallet-query (KDE Wallet)",
93 Self::Security => "security (macOS Keychain)",
94 Self::Pass => "pass (password store)",
95 }
96 }
97
98 /// The argv (program + arguments, no shell) printing the secret at
99 /// `key` on stdout — the value of the `*.command` config field.
100 ///
101 /// `key` is used **verbatim** as the entry identifier: `service` is
102 /// an optional namespace a *self-owning* broker (which stores and
103 /// reads its own value, e.g. an OAuth token manager) adds — a path
104 /// prefix for `pass`/`kwallet`, a distinct attribute for
105 /// `secret-tool`/`security`. Pass `None` to read a pre-existing entry
106 /// exactly as named.
107 pub fn read_command(self, service: Option<&str>, key: &str) -> Vec<String> {
108 match self {
109 Self::SecretTool => match service {
110 Some(service) => {
111 argv(["secret-tool", "lookup", "service", service, "account", key])
112 }
113 None => argv(["secret-tool", "lookup", "account", key]),
114 },
115 Self::KwalletQuery => {
116 let entry = path(service, key);
117 argv(["kwallet-query", "-r", &entry, "kdewallet"])
118 }
119 Self::Security => match service {
120 Some(service) => argv([
121 "security",
122 "find-generic-password",
123 "-s",
124 service,
125 "-a",
126 key,
127 "-w",
128 ]),
129 None => argv(["security", "find-generic-password", "-a", key, "-w"]),
130 },
131 Self::Pass => {
132 let entry = path(service, key);
133 argv(["pass", "show", &entry])
134 }
135 }
136 }
137
138 /// The shell command line *persisting* a secret it receives on stdin
139 /// — the write half of a store/read pair for a broker that owns the
140 /// value (e.g. an OAuth token manager), as opposed to a pre-existing
141 /// entry the user stores themselves. A shell line rather than an
142 /// argv: the writes rely on shell features (`$(cat)` on macOS).
143 pub fn write_command(self, service: Option<&str>, key: &str) -> String {
144 match self {
145 Self::SecretTool => match service {
146 Some(service) => format!(
147 "secret-tool store --label {service}/{key} service {service} account {key}"
148 ),
149 None => format!("secret-tool store --label {key} account {key}"),
150 },
151 Self::KwalletQuery => format!("kwallet-query -w {} kdewallet", path(service, key)),
152 // `security` takes the secret as an argument, not on stdin;
153 // `$(cat)` bridges it, and `-U` overwrites an existing entry.
154 // It also requires `-s`, so an entry without a namespace names
155 // both the service and the account, the read by account alone
156 // still finding it.
157 Self::Security => {
158 let service = service.unwrap_or(key);
159 format!("security add-generic-password -U -s {service} -a {key} -w \"$(cat)\"")
160 }
161 Self::Pass => format!("pass insert -m -f {}", path(service, key)),
162 }
163 }
164}
165
166/// A well-known OAuth 2.0 token broker: an external CLI that owns the
167/// account's refresh token and prints a *fresh* access token on stdout.
168///
169/// Himalaya (and the other Pimalaya tools) ship no OAuth flow of their
170/// own; they call one of these on every connection. Reference
171/// implementation ([`Ortie`](Self::Ortie)) first.
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub enum TokenBroker {
174 /// `ortie`, the Pimalaya OAuth 2.0 token broker.
175 Ortie,
176 /// `pizauth`, an OAuth 2.0 token daemon.
177 Pizauth,
178 /// `oama`, the OAuth Anywhere Mail Agent.
179 Oama,
180}
181
182impl TokenBroker {
183 /// The known brokers, the ones actually installed first and the rest
184 /// in their wake, each group keeping the reference-implementation
185 /// order. Not OS-specific: all are cross-platform CLIs.
186 pub fn available() -> Vec<Self> {
187 let mut brokers = vec![Self::Ortie, Self::Pizauth, Self::Oama];
188
189 // NOTE: a stable sort, as in `KeyringProvider::available`.
190 brokers.sort_by_key(|broker| !broker.installed());
191
192 brokers
193 }
194
195 /// Whether this broker's CLI is found on the `PATH`. Most users run
196 /// one broker at most, so the one they have leads the list rather
197 /// than sitting under two they never installed.
198 pub fn installed(self) -> bool {
199 installed(&self.read_command(""))
200 }
201
202 /// Display name of the broker, for the pick-list labels.
203 pub fn name(self) -> &'static str {
204 match self {
205 Self::Ortie => "ortie (Pimalaya OAuth 2.0 token broker)",
206 Self::Pizauth => "pizauth (OAuth 2.0 token daemon)",
207 Self::Oama => "oama (OAuth Anywhere Mail Agent)",
208 }
209 }
210
211 /// The argv (program + arguments, no shell) printing a fresh access
212 /// token for `account` on stdout — the value of the `*.command`
213 /// config field.
214 ///
215 /// `account` is the broker's own account handle: a name for `ortie`
216 /// and `pizauth`, the email address for `oama`. It defaults to the
217 /// Himalaya account name; the user adjusts it to match the broker's
218 /// configuration.
219 pub fn read_command(self, account: &str) -> Vec<String> {
220 match self {
221 Self::Ortie => argv(["ortie", "token", "show", "-a", account]),
222 Self::Pizauth => argv(["pizauth", "show", account]),
223 Self::Oama => argv(["oama", "access", account]),
224 }
225 }
226}
227
228/// Whether the program `argv` starts with is found on the `PATH`.
229///
230/// An empty `PATH`, or none at all, answers no for everything, which
231/// leaves the pick list in its native order rather than pretending
232/// nothing is installed in a way the labels would announce.
233fn installed(argv: &[String]) -> bool {
234 let Some(program) = argv.first() else {
235 return false;
236 };
237
238 let Some(paths) = env::var_os("PATH") else {
239 return false;
240 };
241
242 found_in(&paths, program)
243}
244
245/// Whether `program` sits in one of the directories `paths` lists.
246///
247/// Split from [`installed`] so the lookup is testable against a `PATH`
248/// built for the test rather than the one the test runner inherited.
249fn found_in(paths: &OsStr, program: &str) -> bool {
250 env::split_paths(paths).any(|dir| found_at(&dir, program))
251}
252
253/// Whether `program` is a file in `dir`.
254#[cfg(not(windows))]
255fn found_at(dir: &Path, program: &str) -> bool {
256 dir.join(program).is_file()
257}
258
259/// Whether `program` is a file in `dir`, under its bare name or under
260/// one of the extensions Windows appends to it.
261///
262/// A bare name is not a filename there, and while the keyring providers
263/// are all absent on Windows, the token brokers are not.
264#[cfg(windows)]
265fn found_at(dir: &Path, program: &str) -> bool {
266 dir.join(program).is_file()
267 || WINDOWS_EXTENSIONS
268 .iter()
269 .any(|extension| dir.join(format!("{program}{extension}")).is_file())
270}
271
272/// Collects `parts` into an owned argv vector.
273fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
274 parts.iter().map(|part| part.to_string()).collect()
275}
276
277/// Renders a path-based entry: `key` alone, or `service/key` when a
278/// namespace is given.
279fn path(service: Option<&str>, key: &str) -> String {
280 match service {
281 Some(service) => format!("{service}/{key}"),
282 None => key.to_owned(),
283 }
284}
285
286/// A secret collected by the picker.
287///
288/// A known provider or broker yields an argv [`Command`](Self::Command)
289/// (serialized as a TOML array); a user-typed command is a
290/// [`Shell`](Self::Shell) line (serialized as a string), the fallback
291/// form run through the platform shell.
292pub enum SecretChoice {
293 /// An argv command (program + arguments, no shell) whose stdout is
294 /// the secret. The preferred form.
295 Command(Vec<String>),
296 /// A raw shell command line whose stdout is the secret — the
297 /// fallback, run through the platform shell.
298 Shell(String),
299 /// The secret stored raw (plaintext) in the configuration.
300 Raw(SecretString),
301}
302
303/// One entry in the secret pick list: a keyring provider, an OAuth
304/// broker, a custom command, or a raw value.
305///
306/// A provider and a broker carry whether their CLI was found on the
307/// `PATH`, which the label says out loud: an entry that cannot run today
308/// is still offered, since installing it afterwards makes the very same
309/// configuration work.
310#[derive(Eq, PartialEq)]
311enum Choice {
312 Keyring {
313 provider: KeyringProvider,
314 installed: bool,
315 },
316 Broker {
317 broker: TokenBroker,
318 installed: bool,
319 },
320 Custom,
321 Raw,
322}
323
324impl fmt::Display for Choice {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 match self {
327 Self::Keyring {
328 provider,
329 installed,
330 } => write!(f, "{}", label(provider.name(), *installed)),
331 Self::Broker { broker, installed } => {
332 write!(f, "{}", label(broker.name(), *installed))
333 }
334 Self::Custom => f.write_str("Custom shell command"),
335 Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
336 }
337 }
338}
339
340/// Appends the missing-CLI mention to a pick-list label, leaving an
341/// installed entry named plainly.
342fn label(name: &str, installed: bool) -> String {
343 if installed {
344 return name.to_owned();
345 }
346
347 format!("{name}, not found on PATH")
348}
349
350/// Prompts for a password: a pick list of the OS keyring providers, then
351/// a custom command, then a raw value. The providers whose CLI is found
352/// on the `PATH` lead the list, the rest saying they are missing.
353///
354/// `key_default` seeds the entry prompt, and the chosen entry is used
355/// verbatim: a pre-existing secret is read exactly as named.
356pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
357 let mut choices = keyring_choices();
358 choices.push(Choice::Custom);
359 choices.push(Choice::Raw);
360
361 prompt_choice(label, key_default, choices)
362}
363
364/// Prompts for an API token: a pick list combining the OS keyrings (for
365/// a token the user generated on the provider and stored themselves) and,
366/// when `oauth` is true, the OAuth 2.0 token brokers (which refresh and
367/// print a fresh token on every read), then a custom command and a raw
368/// value. Same aim as [`prompt_secret`] — a command that returns the
369/// token — merging both acquisition paths behind one strategy prompt. The
370/// brokers are hidden unless the service advertises OAuth; within each
371/// family, the ones whose CLI is found on the `PATH` lead.
372pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
373 let mut choices = keyring_choices();
374 if oauth {
375 choices.extend(
376 TokenBroker::available()
377 .into_iter()
378 .map(|broker| Choice::Broker {
379 installed: broker.installed(),
380 broker,
381 }),
382 );
383 }
384 choices.push(Choice::Custom);
385 choices.push(Choice::Raw);
386
387 prompt_choice(label, key_default, choices)
388}
389
390/// The keyring providers as pick-list entries, in the order
391/// [`KeyringProvider::available`] resolved and each labelled with
392/// whether its CLI is there.
393fn keyring_choices() -> Vec<Choice> {
394 KeyringProvider::available()
395 .into_iter()
396 .map(|provider| Choice::Keyring {
397 installed: provider.installed(),
398 provider,
399 })
400 .collect()
401}
402
403/// Renders the pick list and resolves the selection into a
404/// [`SecretChoice`].
405///
406/// `label` names the secret ("IMAP password", "API token") and
407/// `key_default` seeds the entry/account prompt. A keyring entry is used
408/// **verbatim** (no namespace), so a pre-existing secret is read exactly
409/// as named; the value must already be stored under it, and a missing one
410/// surfaces when the caller tests the account right after.
411fn prompt_choice(
412 label: &str,
413 key_default: &str,
414 choices: Vec<Choice>,
415) -> PromptResult<SecretChoice> {
416 match prompt::item(format!("{label} strategy:"), choices, None)? {
417 Choice::Keyring { provider, .. } => {
418 let key = prompt::text(
419 format!("{label} keyring entry:"),
420 Some(key_default.to_owned()),
421 )?;
422
423 Ok(SecretChoice::Command(provider.read_command(None, &key)))
424 }
425 Choice::Broker { broker, .. } => {
426 let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
427
428 Ok(SecretChoice::Command(broker.read_command(&account)))
429 }
430 Choice::Custom => {
431 let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
432 Ok(SecretChoice::Shell(command))
433 }
434 Choice::Raw => {
435 let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
436 Ok(SecretChoice::Raw(secret))
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use std::{fs, path::PathBuf};
444
445 use super::*;
446
447 #[test]
448 fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
449 let entry = "pimalaya/posteo";
450 assert_eq!(
451 KeyringProvider::Pass.read_command(None, entry),
452 ["pass", "show", "pimalaya/posteo"],
453 );
454 assert_eq!(
455 KeyringProvider::SecretTool.read_command(None, entry),
456 ["secret-tool", "lookup", "account", "pimalaya/posteo"],
457 );
458 assert_eq!(
459 KeyringProvider::Security.read_command(None, entry),
460 [
461 "security",
462 "find-generic-password",
463 "-a",
464 "pimalaya/posteo",
465 "-w"
466 ],
467 );
468 assert_eq!(
469 KeyringProvider::KwalletQuery.read_command(None, entry),
470 ["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
471 );
472 }
473
474 #[test]
475 fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
476 let (service, account) = (Some("ortie"), "acme");
477 assert_eq!(
478 KeyringProvider::Pass.read_command(service, account),
479 ["pass", "show", "ortie/acme"],
480 );
481 assert_eq!(
482 KeyringProvider::SecretTool.read_command(service, account),
483 [
484 "secret-tool",
485 "lookup",
486 "service",
487 "ortie",
488 "account",
489 "acme"
490 ],
491 );
492 assert_eq!(
493 KeyringProvider::Security.read_command(service, account),
494 [
495 "security",
496 "find-generic-password",
497 "-s",
498 "ortie",
499 "-a",
500 "acme",
501 "-w"
502 ],
503 );
504 }
505
506 #[test]
507 fn keyring_write_command_names_a_security_service_without_a_namespace() {
508 assert_eq!(
509 KeyringProvider::Security.write_command(None, "acme"),
510 "security add-generic-password -U -s acme -a acme -w \"$(cat)\"",
511 );
512 assert_eq!(
513 KeyringProvider::Security.write_command(Some("ortie"), "acme"),
514 "security add-generic-password -U -s ortie -a acme -w \"$(cat)\"",
515 );
516 }
517
518 #[test]
519 fn broker_read_command_targets_the_account_per_broker() {
520 assert_eq!(
521 TokenBroker::Ortie.read_command("acme"),
522 ["ortie", "token", "show", "-a", "acme"],
523 );
524 assert_eq!(
525 TokenBroker::Pizauth.read_command("acme"),
526 ["pizauth", "show", "acme"]
527 );
528 assert_eq!(
529 TokenBroker::Oama.read_command("me@acme.test"),
530 ["oama", "access", "me@acme.test"],
531 );
532 }
533
534 #[test]
535 fn available_lists_are_non_empty() {
536 assert!(!TokenBroker::available().is_empty());
537 if cfg!(unix) {
538 assert!(!KeyringProvider::available().is_empty());
539 }
540 }
541
542 /// Whatever this machine has installed, the two groups never
543 /// interleave: once the list reaches an entry that is not there,
544 /// nothing installed follows.
545 #[test]
546 fn available_lists_the_installed_entries_first() {
547 let providers = KeyringProvider::available();
548 if let Some(missing) = providers.iter().position(|p| !p.installed()) {
549 assert!(providers[missing..].iter().all(|p| !p.installed()));
550 }
551
552 let brokers = TokenBroker::available();
553 if let Some(missing) = brokers.iter().position(|b| !b.installed()) {
554 assert!(brokers[missing..].iter().all(|b| !b.installed()));
555 }
556 }
557
558 #[test]
559 fn a_program_is_looked_up_in_every_path_entry() {
560 let dir = env::temp_dir().join("pimalaya-cli-keyring-path");
561 fs::create_dir_all(&dir).expect("create the PATH directory");
562 let program = "pimalaya-cli-test-provider";
563 fs::write(dir.join(program), "").expect("write the program");
564
565 let paths =
566 env::join_paths([PathBuf::from("/nonexistent"), dir.clone()]).expect("join the paths");
567
568 assert!(found_in(&paths, program));
569 assert!(!found_in(&paths, "pimalaya-cli-test-missing"));
570
571 // A `PATH` that does not hold the directory does not find it,
572 // which is what the labels report.
573 let elsewhere = env::join_paths([PathBuf::from("/nonexistent")]).expect("join the paths");
574 assert!(!found_in(&elsewhere, program));
575
576 fs::remove_file(dir.join(program)).expect("remove the program");
577 }
578
579 #[test]
580 fn an_entry_that_is_not_there_says_so() {
581 assert_eq!(
582 label("pass (password store)", true),
583 "pass (password store)"
584 );
585 assert_eq!(
586 label("pass (password store)", false),
587 "pass (password store), not found on PATH",
588 );
589 }
590
591 #[test]
592 fn an_empty_command_is_not_installed() {
593 assert!(!installed(&[]));
594 }
595}