Skip to main content

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 => 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/// The argv looking up the `secret-tool` entry whose `attribute` is `key`.
278///
279/// `account` is the attribute a pre-existing entry is read by when none is
280/// named; a KeePassXC entry keyed by its `Title` is read by `Title`.
281fn secret_tool_lookup(attribute: &str, key: &str) -> Vec<String> {
282    argv(["secret-tool", "lookup", attribute, key])
283}
284
285/// Renders a path-based entry: `key` alone, or `service/key` when a
286/// namespace is given.
287fn path(service: Option<&str>, key: &str) -> String {
288    match service {
289        Some(service) => format!("{service}/{key}"),
290        None => key.to_owned(),
291    }
292}
293
294/// A secret collected by the picker.
295///
296/// A known provider or broker yields an argv [`Command`](Self::Command)
297/// (serialized as a TOML array); a user-typed command is a
298/// [`Shell`](Self::Shell) line (serialized as a string), the fallback
299/// form run through the platform shell.
300pub enum SecretChoice {
301    /// An argv command (program + arguments, no shell) whose stdout is
302    /// the secret. The preferred form.
303    Command(Vec<String>),
304    /// A raw shell command line whose stdout is the secret — the
305    /// fallback, run through the platform shell.
306    Shell(String),
307    /// The secret stored raw (plaintext) in the configuration.
308    Raw(SecretString),
309}
310
311/// One entry in the secret pick list: a keyring provider, an OAuth
312/// broker, a custom command, or a raw value.
313///
314/// A provider and a broker carry whether their CLI was found on the
315/// `PATH`, which the label says out loud: an entry that cannot run today
316/// is still offered, since installing it afterwards makes the very same
317/// configuration work.
318#[derive(Eq, PartialEq)]
319enum Choice {
320    Keyring {
321        provider: KeyringProvider,
322        installed: bool,
323    },
324    Broker {
325        broker: TokenBroker,
326        installed: bool,
327    },
328    Custom,
329    Raw,
330}
331
332impl fmt::Display for Choice {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        match self {
335            Self::Keyring {
336                provider,
337                installed,
338            } => write!(f, "{}", label(provider.name(), *installed)),
339            Self::Broker { broker, installed } => {
340                write!(f, "{}", label(broker.name(), *installed))
341            }
342            Self::Custom => f.write_str("Custom shell command"),
343            Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
344        }
345    }
346}
347
348/// Appends the missing-CLI mention to a pick-list label, leaving an
349/// installed entry named plainly.
350fn label(name: &str, installed: bool) -> String {
351    if installed {
352        return name.to_owned();
353    }
354
355    format!("{name}, not found on PATH")
356}
357
358/// Prompts for a password: a pick list of the OS keyring providers, then
359/// a custom command, then a raw value. The providers whose CLI is found
360/// on the `PATH` lead the list, the rest saying they are missing.
361///
362/// `key_default` seeds the entry prompt, and the chosen entry is used
363/// verbatim: a pre-existing secret is read exactly as named.
364pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
365    let mut choices = keyring_choices();
366    choices.push(Choice::Custom);
367    choices.push(Choice::Raw);
368
369    prompt_choice(label, key_default, choices)
370}
371
372/// Prompts for an API token: a pick list combining the OS keyrings (for
373/// a token the user generated on the provider and stored themselves) and,
374/// when `oauth` is true, the OAuth 2.0 token brokers (which refresh and
375/// print a fresh token on every read), then a custom command and a raw
376/// value. Same aim as [`prompt_secret`] — a command that returns the
377/// token — merging both acquisition paths behind one strategy prompt. The
378/// brokers are hidden unless the service advertises OAuth; within each
379/// family, the ones whose CLI is found on the `PATH` lead.
380pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
381    let mut choices = keyring_choices();
382    if oauth {
383        choices.extend(
384            TokenBroker::available()
385                .into_iter()
386                .map(|broker| Choice::Broker {
387                    installed: broker.installed(),
388                    broker,
389                }),
390        );
391    }
392    choices.push(Choice::Custom);
393    choices.push(Choice::Raw);
394
395    prompt_choice(label, key_default, choices)
396}
397
398/// The keyring providers as pick-list entries, in the order
399/// [`KeyringProvider::available`] resolved and each labelled with
400/// whether its CLI is there.
401fn keyring_choices() -> Vec<Choice> {
402    KeyringProvider::available()
403        .into_iter()
404        .map(|provider| Choice::Keyring {
405            installed: provider.installed(),
406            provider,
407        })
408        .collect()
409}
410
411/// Renders the pick list and resolves the selection into a
412/// [`SecretChoice`].
413///
414/// `label` names the secret ("IMAP password", "API token") and
415/// `key_default` seeds the entry/account prompt. A keyring entry is used
416/// **verbatim** (no namespace), so a pre-existing secret is read exactly
417/// as named; the value must already be stored under it, and a missing one
418/// surfaces when the caller tests the account right after.
419fn prompt_choice(
420    label: &str,
421    key_default: &str,
422    choices: Vec<Choice>,
423) -> PromptResult<SecretChoice> {
424    match prompt::item(format!("{label} strategy:"), choices, None)? {
425        Choice::Keyring {
426            provider: KeyringProvider::SecretTool,
427            ..
428        } => {
429            let attribute = prompt::text(
430                format!("{label} keyring attribute:"),
431                Some("account".to_owned()),
432            )?;
433            let key = prompt::text(
434                format!("{label} keyring entry:"),
435                Some(key_default.to_owned()),
436            )?;
437
438            // NOTE: a blank attribute cannot name anything, so it reads
439            // by `account`.
440            let attribute = match attribute.trim() {
441                "" => "account",
442                attribute => attribute,
443            };
444
445            Ok(SecretChoice::Command(secret_tool_lookup(attribute, &key)))
446        }
447        Choice::Keyring { provider, .. } => {
448            let key = prompt::text(
449                format!("{label} keyring entry:"),
450                Some(key_default.to_owned()),
451            )?;
452
453            Ok(SecretChoice::Command(provider.read_command(None, &key)))
454        }
455        Choice::Broker { broker, .. } => {
456            let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
457
458            Ok(SecretChoice::Command(broker.read_command(&account)))
459        }
460        Choice::Custom => {
461            let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
462            Ok(SecretChoice::Shell(command))
463        }
464        Choice::Raw => {
465            let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
466            Ok(SecretChoice::Raw(secret))
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use std::{fs, path::PathBuf};
474
475    use super::*;
476
477    #[test]
478    fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
479        let entry = "pimalaya/posteo";
480        assert_eq!(
481            KeyringProvider::Pass.read_command(None, entry),
482            ["pass", "show", "pimalaya/posteo"],
483        );
484        assert_eq!(
485            KeyringProvider::SecretTool.read_command(None, entry),
486            ["secret-tool", "lookup", "account", "pimalaya/posteo"],
487        );
488        assert_eq!(
489            KeyringProvider::Security.read_command(None, entry),
490            [
491                "security",
492                "find-generic-password",
493                "-a",
494                "pimalaya/posteo",
495                "-w"
496            ],
497        );
498        assert_eq!(
499            KeyringProvider::KwalletQuery.read_command(None, entry),
500            ["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
501        );
502    }
503
504    #[test]
505    fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
506        let (service, account) = (Some("ortie"), "acme");
507        assert_eq!(
508            KeyringProvider::Pass.read_command(service, account),
509            ["pass", "show", "ortie/acme"],
510        );
511        assert_eq!(
512            KeyringProvider::SecretTool.read_command(service, account),
513            [
514                "secret-tool",
515                "lookup",
516                "service",
517                "ortie",
518                "account",
519                "acme"
520            ],
521        );
522        assert_eq!(
523            KeyringProvider::Security.read_command(service, account),
524            [
525                "security",
526                "find-generic-password",
527                "-s",
528                "ortie",
529                "-a",
530                "acme",
531                "-w"
532            ],
533        );
534    }
535
536    #[test]
537    fn keyring_write_command_names_a_security_service_without_a_namespace() {
538        assert_eq!(
539            KeyringProvider::Security.write_command(None, "acme"),
540            "security add-generic-password -U -s acme -a acme -w \"$(cat)\"",
541        );
542        assert_eq!(
543            KeyringProvider::Security.write_command(Some("ortie"), "acme"),
544            "security add-generic-password -U -s ortie -a acme -w \"$(cat)\"",
545        );
546    }
547
548    #[test]
549    fn secret_tool_lookup_reads_the_named_attribute() {
550        assert_eq!(
551            secret_tool_lookup("Title", "disroot"),
552            ["secret-tool", "lookup", "Title", "disroot"],
553        );
554        assert_eq!(
555            KeyringProvider::SecretTool.read_command(None, "disroot"),
556            secret_tool_lookup("account", "disroot"),
557        );
558    }
559
560    #[test]
561    fn broker_read_command_targets_the_account_per_broker() {
562        assert_eq!(
563            TokenBroker::Ortie.read_command("acme"),
564            ["ortie", "token", "show", "-a", "acme"],
565        );
566        assert_eq!(
567            TokenBroker::Pizauth.read_command("acme"),
568            ["pizauth", "show", "acme"]
569        );
570        assert_eq!(
571            TokenBroker::Oama.read_command("me@acme.test"),
572            ["oama", "access", "me@acme.test"],
573        );
574    }
575
576    #[test]
577    fn available_lists_are_non_empty() {
578        assert!(!TokenBroker::available().is_empty());
579        if cfg!(unix) {
580            assert!(!KeyringProvider::available().is_empty());
581        }
582    }
583
584    /// Whatever this machine has installed, the two groups never
585    /// interleave: once the list reaches an entry that is not there,
586    /// nothing installed follows.
587    #[test]
588    fn available_lists_the_installed_entries_first() {
589        let providers = KeyringProvider::available();
590        if let Some(missing) = providers.iter().position(|p| !p.installed()) {
591            assert!(providers[missing..].iter().all(|p| !p.installed()));
592        }
593
594        let brokers = TokenBroker::available();
595        if let Some(missing) = brokers.iter().position(|b| !b.installed()) {
596            assert!(brokers[missing..].iter().all(|b| !b.installed()));
597        }
598    }
599
600    #[test]
601    fn a_program_is_looked_up_in_every_path_entry() {
602        let dir = env::temp_dir().join("pimalaya-cli-keyring-path");
603        fs::create_dir_all(&dir).expect("create the PATH directory");
604        let program = "pimalaya-cli-test-provider";
605        fs::write(dir.join(program), "").expect("write the program");
606
607        let paths =
608            env::join_paths([PathBuf::from("/nonexistent"), dir.clone()]).expect("join the paths");
609
610        assert!(found_in(&paths, program));
611        assert!(!found_in(&paths, "pimalaya-cli-test-missing"));
612
613        // A `PATH` that does not hold the directory does not find it,
614        // which is what the labels report.
615        let elsewhere = env::join_paths([PathBuf::from("/nonexistent")]).expect("join the paths");
616        assert!(!found_in(&elsewhere, program));
617
618        fs::remove_file(dir.join(program)).expect("remove the program");
619    }
620
621    #[test]
622    fn an_entry_that_is_not_there_says_so() {
623        assert_eq!(
624            label("pass (password store)", true),
625            "pass (password store)"
626        );
627        assert_eq!(
628            label("pass (password store)", false),
629            "pass (password store), not found on PATH",
630        );
631    }
632
633    #[test]
634    fn an_empty_command_is_not_installed() {
635        assert!(!installed(&[]));
636    }
637}