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. [`prompt_secret`] and [`prompt_token`] only
14//! record the read command, leaving the value for the user to store
15//! under the chosen entry beforehand. [`prompt_password`] asks for the
16//! password once and [`prompt_store`] stores it after the caller tested
17//! it, reading it back before answering the read command.
18//!
19//! Both lists are ordered by what the running system can actually do:
20//! the entries whose CLI is found on the `PATH` lead, the rest follow
21//! and say so. Nothing is hidden, since a provider missing today is one
22//! package install away and the configuration written for it is correct
23//! either way.
24
25use core::fmt;
26use std::{
27    env,
28    ffi::OsStr,
29    io::{self, Write},
30    path::Path,
31    process::{Command, ExitStatus, Stdio},
32};
33
34use secrecy::{ExposeSecret, SecretSlice, SecretString};
35
36use crate::prompt::{self, PromptResult};
37
38/// Extensions Windows appends to a bare program name, tried after the
39/// name itself. The `PATHEXT` list in practice, minus the entries no
40/// credential CLI ships as.
41#[cfg(windows)]
42const WINDOWS_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
43
44/// A well-known credential-provider CLI a password can be read from.
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum KeyringProvider {
47    /// `secret-tool`, over the Secret Service (GNOME Keyring).
48    SecretTool,
49    /// `kwallet-query`, over the KDE Wallet.
50    KwalletQuery,
51    /// `security`, over the macOS Keychain.
52    Security,
53    /// `pass`, the standard unix password manager.
54    Pass,
55}
56
57impl KeyringProvider {
58    /// The providers relevant on the running OS, the ones actually
59    /// installed first and the rest in their wake, each group keeping
60    /// the native-first order. Empty on platforms without a known
61    /// stdin-friendly provider (Windows), where the picker goes straight
62    /// to a custom command.
63    pub fn available() -> Vec<Self> {
64        let mut providers = Vec::new();
65
66        if cfg!(target_os = "linux") {
67            providers.push(Self::SecretTool);
68            providers.push(Self::KwalletQuery);
69        }
70
71        if cfg!(target_os = "macos") {
72            providers.push(Self::Security);
73        }
74
75        if cfg!(unix) {
76            providers.push(Self::Pass);
77        }
78
79        // NOTE: a stable sort, so the native-first order above survives
80        // inside the installed and the missing group alike.
81        providers.sort_by_key(|provider| !provider.installed());
82
83        providers
84    }
85
86    /// Whether this provider's CLI is found on the `PATH`, which is what
87    /// leads the pick list: an installed provider is one a secret can be
88    /// stored in today, while the rest are a package install away.
89    ///
90    /// The provider names its own program, being the first element of
91    /// its read command, so no table of binary names is duplicated here.
92    pub fn installed(self) -> bool {
93        installed(&self.read_command(None, ""))
94    }
95
96    /// Display name of the provider, for the pick-list labels.
97    pub fn name(self) -> &'static str {
98        match self {
99            Self::SecretTool => "secret-tool (GNOME Keyring / Secret Service)",
100            Self::KwalletQuery => "kwallet-query (KDE Wallet)",
101            Self::Security => "security (macOS Keychain)",
102            Self::Pass => "pass (password store)",
103        }
104    }
105
106    /// The argv (program + arguments, no shell) printing the secret at
107    /// `key` on stdout — the value of the `*.command` config field.
108    ///
109    /// `key` is used **verbatim** as the entry identifier: `service` is
110    /// an optional namespace a *self-owning* broker (which stores and
111    /// reads its own value, e.g. an OAuth token manager) adds — a path
112    /// prefix for `pass`/`kwallet`, a distinct attribute for
113    /// `secret-tool`/`security`. Pass `None` to read a pre-existing entry
114    /// exactly as named.
115    pub fn read_command(self, service: Option<&str>, key: &str) -> Vec<String> {
116        match self {
117            Self::SecretTool => match service {
118                Some(service) => {
119                    argv(["secret-tool", "lookup", "service", service, "account", key])
120                }
121                None => secret_tool_lookup("account", key),
122            },
123            Self::KwalletQuery => {
124                let entry = path(service, key);
125                argv(["kwallet-query", "-r", &entry, "kdewallet"])
126            }
127            Self::Security => match service {
128                Some(service) => argv([
129                    "security",
130                    "find-generic-password",
131                    "-s",
132                    service,
133                    "-a",
134                    key,
135                    "-w",
136                ]),
137                None => argv(["security", "find-generic-password", "-a", key, "-w"]),
138            },
139            Self::Pass => {
140                let entry = path(service, key);
141                argv(["pass", "show", &entry])
142            }
143        }
144    }
145
146    /// The shell command line *persisting* a secret it receives on stdin
147    /// — the write half of a store/read pair for a broker that owns the
148    /// value (e.g. an OAuth token manager), as opposed to a pre-existing
149    /// entry the user stores themselves. A shell line rather than an
150    /// argv: the writes rely on shell features (`$(cat)` on macOS).
151    pub fn write_command(self, service: Option<&str>, key: &str) -> String {
152        match self {
153            Self::SecretTool => match service {
154                Some(service) => format!(
155                    "secret-tool store --label {service}/{key} service {service} account {key}"
156                ),
157                None => format!("secret-tool store --label {key} account {key}"),
158            },
159            Self::KwalletQuery => format!("kwallet-query -w {} kdewallet", path(service, key)),
160            // `security` takes the secret as an argument, not on stdin;
161            // `$(cat)` bridges it, and `-U` overwrites an existing entry.
162            // It also requires `-s`, so an entry without a namespace names
163            // both the service and the account, the read by account alone
164            // still finding it.
165            Self::Security => {
166                let service = service.unwrap_or(key);
167                format!("security add-generic-password -U -s {service} -a {key} -w \"$(cat)\"")
168            }
169            Self::Pass => format!("pass insert -m -f {}", path(service, key)),
170        }
171    }
172
173    /// Stores `secret` at `key`, the entry `read_command(None, key)` reads,
174    /// writing it on the program's stdin and never on its arguments.
175    pub fn store(self, key: &str, secret: &SecretString) -> io::Result<()> {
176        let line = match self {
177            Self::Security => Some(security_line(key, secret)?),
178            _ => None,
179        };
180
181        let input = line.as_ref().unwrap_or(secret);
182        let argv = self.store_command(key);
183
184        let mut child = Command::new(&argv[0])
185            .args(&argv[1..])
186            .stdin(Stdio::piped())
187            .stdout(Stdio::null())
188            .stderr(Stdio::piped())
189            .spawn()?;
190
191        // NOTE: a broken pipe says less than the program's stderr.
192        let written = match child.stdin.take() {
193            Some(mut stdin) => stdin.write_all(input.expose_secret().as_bytes()),
194            None => Ok(()),
195        };
196
197        let output = child.wait_with_output()?;
198
199        if !output.status.success() {
200            return Err(failure(&argv[0], &output.stderr, output.status));
201        }
202
203        written
204    }
205
206    /// The argv storing the entry at `key`, the secret going on its stdin.
207    fn store_command(self, key: &str) -> Vec<String> {
208        match self {
209            Self::SecretTool => argv(["secret-tool", "store", "--label", key, "account", key]),
210            Self::KwalletQuery => argv(["kwallet-query", "-w", key, "kdewallet"]),
211            Self::Security => argv(["security", "-i"]),
212            Self::Pass => argv(["pass", "insert", "-m", "-f", key]),
213        }
214    }
215
216    /// Reads the entry at `key` back, failing unless it holds `secret`.
217    fn read_back(self, key: &str, secret: &SecretString) -> io::Result<()> {
218        let argv = self.read_command(None, key);
219
220        let output = Command::new(&argv[0])
221            .args(&argv[1..])
222            .stdin(Stdio::null())
223            .output()?;
224
225        let stdout = SecretSlice::from(output.stdout);
226
227        if !output.status.success() {
228            return Err(failure(&argv[0], &output.stderr, output.status));
229        }
230
231        let stored = stdout.expose_secret();
232        let stored = stored.strip_suffix(b"\n").unwrap_or(stored);
233
234        if stored != secret.expose_secret().as_bytes() {
235            return Err(io::Error::other("Entry read back does not match"));
236        }
237
238        Ok(())
239    }
240}
241
242/// A well-known OAuth 2.0 token broker: an external CLI that owns the
243/// account's refresh token and prints a *fresh* access token on stdout.
244///
245/// Himalaya (and the other Pimalaya tools) ship no OAuth flow of their
246/// own; they call one of these on every connection. Reference
247/// implementation ([`Ortie`](Self::Ortie)) first.
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub enum TokenBroker {
250    /// `ortie`, the Pimalaya OAuth 2.0 token broker.
251    Ortie,
252    /// `pizauth`, an OAuth 2.0 token daemon.
253    Pizauth,
254    /// `oama`, the OAuth Anywhere Mail Agent.
255    Oama,
256}
257
258impl TokenBroker {
259    /// The known brokers, the ones actually installed first and the rest
260    /// in their wake, each group keeping the reference-implementation
261    /// order. Not OS-specific: all are cross-platform CLIs.
262    pub fn available() -> Vec<Self> {
263        let mut brokers = vec![Self::Ortie, Self::Pizauth, Self::Oama];
264
265        // NOTE: a stable sort, as in `KeyringProvider::available`.
266        brokers.sort_by_key(|broker| !broker.installed());
267
268        brokers
269    }
270
271    /// Whether this broker's CLI is found on the `PATH`. Most users run
272    /// one broker at most, so the one they have leads the list rather
273    /// than sitting under two they never installed.
274    pub fn installed(self) -> bool {
275        installed(&self.read_command(""))
276    }
277
278    /// Display name of the broker, for the pick-list labels.
279    pub fn name(self) -> &'static str {
280        match self {
281            Self::Ortie => "ortie (Pimalaya OAuth 2.0 token broker)",
282            Self::Pizauth => "pizauth (OAuth 2.0 token daemon)",
283            Self::Oama => "oama (OAuth Anywhere Mail Agent)",
284        }
285    }
286
287    /// The argv (program + arguments, no shell) printing a fresh access
288    /// token for `account` on stdout — the value of the `*.command`
289    /// config field.
290    ///
291    /// `account` is the broker's own account handle: a name for `ortie`
292    /// and `pizauth`, the email address for `oama`. It defaults to the
293    /// Himalaya account name; the user adjusts it to match the broker's
294    /// configuration.
295    pub fn read_command(self, account: &str) -> Vec<String> {
296        match self {
297            Self::Ortie => argv(["ortie", "token", "show", "-a", account]),
298            Self::Pizauth => argv(["pizauth", "show", account]),
299            Self::Oama => argv(["oama", "access", account]),
300        }
301    }
302}
303
304/// Whether the program `argv` starts with is found on the `PATH`.
305///
306/// An empty `PATH`, or none at all, answers no for everything, which
307/// leaves the pick list in its native order rather than pretending
308/// nothing is installed in a way the labels would announce.
309fn installed(argv: &[String]) -> bool {
310    let Some(program) = argv.first() else {
311        return false;
312    };
313
314    let Some(paths) = env::var_os("PATH") else {
315        return false;
316    };
317
318    found_in(&paths, program)
319}
320
321/// Whether `program` sits in one of the directories `paths` lists.
322///
323/// Split from [`installed`] so the lookup is testable against a `PATH`
324/// built for the test rather than the one the test runner inherited.
325fn found_in(paths: &OsStr, program: &str) -> bool {
326    env::split_paths(paths).any(|dir| found_at(&dir, program))
327}
328
329/// Whether `program` is a file in `dir`.
330#[cfg(not(windows))]
331fn found_at(dir: &Path, program: &str) -> bool {
332    dir.join(program).is_file()
333}
334
335/// Whether `program` is a file in `dir`, under its bare name or under
336/// one of the extensions Windows appends to it.
337///
338/// A bare name is not a filename there, and while the keyring providers
339/// are all absent on Windows, the token brokers are not.
340#[cfg(windows)]
341fn found_at(dir: &Path, program: &str) -> bool {
342    dir.join(program).is_file()
343        || WINDOWS_EXTENSIONS
344            .iter()
345            .any(|extension| dir.join(format!("{program}{extension}")).is_file())
346}
347
348/// Collects `parts` into an owned argv vector.
349fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
350    parts.iter().map(|part| part.to_string()).collect()
351}
352
353/// The argv looking up the `secret-tool` entry whose `attribute` is `key`.
354///
355/// `account` is the attribute a pre-existing entry is read by when none is
356/// named; a KeePassXC entry keyed by its `Title` is read by `Title`.
357fn secret_tool_lookup(attribute: &str, key: &str) -> Vec<String> {
358    argv(["secret-tool", "lookup", attribute, key])
359}
360
361/// Renders a path-based entry: `key` alone, or `service/key` when a
362/// namespace is given.
363fn path(service: Option<&str>, key: &str) -> String {
364    match service {
365        Some(service) => format!("{service}/{key}"),
366        None => key.to_owned(),
367    }
368}
369
370/// The `security -i` line storing `secret` at `key`, its values quoted.
371///
372/// A line break would start another command, so one is refused.
373fn security_line(key: &str, secret: &SecretString) -> io::Result<SecretString> {
374    let secret = secret.expose_secret();
375
376    if key.contains(['\n', '\r']) || secret.contains(['\n', '\r']) {
377        return Err(io::Error::new(
378            io::ErrorKind::InvalidInput,
379            "Cannot store a key or a secret holding a line break",
380        ));
381    }
382
383    // NOTE: sized up front, so no reallocation leaves a copy of the secret.
384    let mut line = String::with_capacity(64 + 4 * key.len() + 2 * secret.len());
385
386    line.push_str("add-generic-password -U -s ");
387    push_quoted(&mut line, key);
388    line.push_str(" -a ");
389    push_quoted(&mut line, key);
390    line.push_str(" -w ");
391    push_quoted(&mut line, secret);
392    line.push('\n');
393
394    Ok(line.into())
395}
396
397/// Appends `value` to `line` double-quoted, `"` and `\` escaped.
398fn push_quoted(line: &mut String, value: &str) {
399    line.push('"');
400
401    for c in value.chars() {
402        if matches!(c, '"' | '\\') {
403            line.push('\\');
404        }
405
406        line.push(c);
407    }
408
409    line.push('"');
410}
411
412/// The error of `program` exiting with `status`, from its stderr text.
413fn failure(program: &str, stderr: &[u8], status: ExitStatus) -> io::Error {
414    let stderr = String::from_utf8_lossy(stderr);
415    let stderr = stderr.split_whitespace().collect::<Vec<_>>().join(" ");
416
417    if stderr.is_empty() {
418        return io::Error::other(format!("{program} failed, {status}"));
419    }
420
421    io::Error::other(stderr)
422}
423
424/// The `label` of a prompt as a noun in a sentence: `Password` reads
425/// `password`, an initialism such as `API token` stays as written.
426fn noun(label: &str) -> String {
427    let mut chars = label.chars();
428
429    match (chars.next(), chars.next()) {
430        (Some(first), Some(second)) if second.is_lowercase() => first
431            .to_lowercase()
432            .chain(label[first.len_utf8()..].chars())
433            .collect(),
434        _ => label.to_string(),
435    }
436}
437
438/// Whether the host of `endpoint` is neither `domain` nor a subdomain of it.
439///
440/// The userinfo before the host ends at the last `@`, as in a URL.
441fn off_domain(endpoint: &str, domain: &str) -> bool {
442    let rest = match endpoint.split_once("://") {
443        Some((_, rest)) => rest,
444        None => endpoint,
445    };
446
447    let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
448    let authority = &rest[..end];
449
450    let authority = match authority.rsplit_once('@') {
451        Some((_, authority)) => authority,
452        None => authority,
453    };
454
455    let host = match authority.strip_prefix('[') {
456        Some(ipv6) => ipv6.split(']').next().unwrap_or_default(),
457        None => authority.split(':').next().unwrap_or_default(),
458    };
459
460    let host = host.trim_end_matches('.').to_lowercase();
461    let domain = domain.trim_end_matches('.').to_lowercase();
462
463    host != domain && !host.ends_with(&format!(".{domain}"))
464}
465
466/// A secret collected by the picker.
467///
468/// A known provider or broker yields an argv [`Command`](Self::Command)
469/// (serialized as a TOML array); a user-typed command is a
470/// [`Shell`](Self::Shell) line (serialized as a string), the fallback
471/// form run through the platform shell.
472pub enum SecretChoice {
473    /// An argv command (program + arguments, no shell) whose stdout is
474    /// the secret. The preferred form.
475    Command(Vec<String>),
476    /// A raw shell command line whose stdout is the secret — the
477    /// fallback, run through the platform shell.
478    Shell(String),
479    /// The secret stored raw (plaintext) in the configuration.
480    Raw(SecretString),
481}
482
483/// One entry in the secret pick list: a keyring provider, an OAuth
484/// broker, a custom command, or a raw value.
485///
486/// A provider and a broker carry whether their CLI was found on the
487/// `PATH`, which the label says out loud: an entry that cannot run today
488/// is still offered, since installing it afterwards makes the very same
489/// configuration work.
490#[derive(Eq, PartialEq)]
491enum Choice {
492    Keyring {
493        provider: KeyringProvider,
494        installed: bool,
495    },
496    Broker {
497        broker: TokenBroker,
498        installed: bool,
499    },
500    Custom,
501    Raw,
502}
503
504impl fmt::Display for Choice {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        match self {
507            Self::Keyring {
508                provider,
509                installed,
510            } => write!(f, "{}", label(provider.name(), *installed)),
511            Self::Broker { broker, installed } => {
512                write!(f, "{}", label(broker.name(), *installed))
513            }
514            Self::Custom => f.write_str("Custom shell command"),
515            Self::Raw => f.write_str("Store raw in the configuration (plaintext, NOT recommended)"),
516        }
517    }
518}
519
520/// Appends the missing-CLI mention to a pick-list label, leaving an
521/// installed entry named plainly.
522fn label(name: &str, installed: bool) -> String {
523    if installed {
524        return name.to_owned();
525    }
526
527    format!("{name}, not found on PATH")
528}
529
530/// One entry in the store pick list of [`prompt_store`].
531#[derive(Eq, PartialEq)]
532enum Storage {
533    Keyring(KeyringProvider),
534    Custom,
535    Raw,
536}
537
538impl fmt::Display for Storage {
539    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540        match self {
541            Self::Keyring(provider) => write!(f, "Store it in {}", provider.name()),
542            Self::Custom => f.write_str("Custom shell command printing it"),
543            Self::Raw => f.write_str("Raw in the configuration (plaintext, NOT recommended)"),
544        }
545    }
546}
547
548/// Prompts for a password: a pick list of the OS keyring providers, then
549/// a custom command, then a raw value. The providers whose CLI is found
550/// on the `PATH` lead the list, the rest saying they are missing.
551///
552/// `key_default` seeds the entry prompt, and the chosen entry is used
553/// verbatim: a pre-existing secret is read exactly as named.
554pub fn prompt_secret(label: &str, key_default: &str) -> PromptResult<SecretChoice> {
555    let mut choices = keyring_choices();
556    choices.push(Choice::Custom);
557    choices.push(Choice::Raw);
558
559    prompt_choice(label, key_default, choices)
560}
561
562/// Prompts for an API token: a pick list combining the OS keyrings (for
563/// a token the user generated on the provider and stored themselves) and,
564/// when `oauth` is true, the OAuth 2.0 token brokers (which refresh and
565/// print a fresh token on every read), then a custom command and a raw
566/// value. Same aim as [`prompt_secret`] — a command that returns the
567/// token — merging both acquisition paths behind one strategy prompt. The
568/// brokers are hidden unless the service advertises OAuth; within each
569/// family, the ones whose CLI is found on the `PATH` lead.
570pub fn prompt_token(label: &str, key_default: &str, oauth: bool) -> PromptResult<SecretChoice> {
571    let mut choices = keyring_choices();
572    if oauth {
573        choices.extend(
574            TokenBroker::available()
575                .into_iter()
576                .map(|broker| Choice::Broker {
577                    installed: broker.installed(),
578                    broker,
579                }),
580        );
581    }
582    choices.push(Choice::Custom);
583    choices.push(Choice::Raw);
584
585    prompt_choice(label, key_default, choices)
586}
587
588/// Prompts once for a masked secret, after naming on stderr each of the
589/// `endpoints` it goes to and whether one is off the address's `domain`.
590pub fn prompt_password(
591    label: &str,
592    endpoints: &[&str],
593    domain: Option<&str>,
594) -> PromptResult<SecretString> {
595    let noun = noun(label);
596
597    for endpoint in endpoints {
598        match domain {
599            Some(domain) if off_domain(endpoint, domain) => {
600                eprintln!("Your {noun} goes to {endpoint}, not on {domain}")
601            }
602            _ => eprintln!("Your {noun} goes to {endpoint}"),
603        }
604    }
605
606    let password = prompt::secret(format!("{label}:"))?;
607
608    Ok(password.into())
609}
610
611/// Prompts where to keep a tested secret: an installed keyring, storing it
612/// at `key` and reading it back, a custom command, or the raw value.
613pub fn prompt_store(label: &str, key: &str, secret: &SecretString) -> PromptResult<SecretChoice> {
614    let mut providers: Vec<_> = KeyringProvider::available()
615        .into_iter()
616        .filter(|provider| provider.installed())
617        .collect();
618
619    loop {
620        let mut choices: Vec<_> = providers.iter().copied().map(Storage::Keyring).collect();
621        choices.push(Storage::Custom);
622        choices.push(Storage::Raw);
623
624        match prompt::item(format!("{label} storage:"), choices, None)? {
625            Storage::Keyring(provider) => {
626                let stored = provider
627                    .store(key, secret)
628                    .and_then(|()| provider.read_back(key, secret));
629
630                match stored {
631                    Ok(()) => return Ok(SecretChoice::Command(provider.read_command(None, key))),
632                    Err(err) => {
633                        eprintln!("Cannot store {label} in {}: {err}", provider.name());
634                        providers.retain(|other| *other != provider);
635                    }
636                }
637            }
638            Storage::Custom => {
639                let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
640                return Ok(SecretChoice::Shell(command));
641            }
642            Storage::Raw => return Ok(SecretChoice::Raw(secret.clone())),
643        }
644    }
645}
646
647/// The keyring providers as pick-list entries, in the order
648/// [`KeyringProvider::available`] resolved and each labelled with
649/// whether its CLI is there.
650fn keyring_choices() -> Vec<Choice> {
651    KeyringProvider::available()
652        .into_iter()
653        .map(|provider| Choice::Keyring {
654            installed: provider.installed(),
655            provider,
656        })
657        .collect()
658}
659
660/// Renders the pick list and resolves the selection into a
661/// [`SecretChoice`].
662///
663/// `label` names the secret ("IMAP password", "API token") and
664/// `key_default` seeds the entry/account prompt. A keyring entry is used
665/// **verbatim** (no namespace), so a pre-existing secret is read exactly
666/// as named; the value must already be stored under it, and a missing one
667/// surfaces when the caller tests the account right after.
668fn prompt_choice(
669    label: &str,
670    key_default: &str,
671    choices: Vec<Choice>,
672) -> PromptResult<SecretChoice> {
673    match prompt::item(format!("{label} strategy:"), choices, None)? {
674        Choice::Keyring {
675            provider: KeyringProvider::SecretTool,
676            ..
677        } => {
678            let attribute = prompt::text(
679                format!("{label} keyring attribute:"),
680                Some("account".to_owned()),
681            )?;
682            let key = prompt::text(
683                format!("{label} keyring entry:"),
684                Some(key_default.to_owned()),
685            )?;
686
687            // NOTE: a blank attribute cannot name anything, so it reads
688            // by `account`.
689            let attribute = match attribute.trim() {
690                "" => "account",
691                attribute => attribute,
692            };
693
694            Ok(SecretChoice::Command(secret_tool_lookup(attribute, &key)))
695        }
696        Choice::Keyring { provider, .. } => {
697            let key = prompt::text(
698                format!("{label} keyring entry:"),
699                Some(key_default.to_owned()),
700            )?;
701
702            Ok(SecretChoice::Command(provider.read_command(None, &key)))
703        }
704        Choice::Broker { broker, .. } => {
705            let account = prompt::text(format!("{label} account:"), Some(key_default.to_owned()))?;
706
707            Ok(SecretChoice::Command(broker.read_command(&account)))
708        }
709        Choice::Custom => {
710            let command = prompt::text(format!("{label} shell command:"), None::<String>)?;
711            Ok(SecretChoice::Shell(command))
712        }
713        Choice::Raw => {
714            let secret = prompt::password(format!("{label}:"), format!("Confirm {label}:"))?;
715            Ok(SecretChoice::Raw(secret))
716        }
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use std::{fs, path::PathBuf};
723
724    use super::*;
725
726    #[test]
727    fn keyring_read_command_uses_the_entry_verbatim_without_a_namespace() {
728        let entry = "pimalaya/posteo";
729        assert_eq!(
730            KeyringProvider::Pass.read_command(None, entry),
731            ["pass", "show", "pimalaya/posteo"],
732        );
733        assert_eq!(
734            KeyringProvider::SecretTool.read_command(None, entry),
735            ["secret-tool", "lookup", "account", "pimalaya/posteo"],
736        );
737        assert_eq!(
738            KeyringProvider::Security.read_command(None, entry),
739            [
740                "security",
741                "find-generic-password",
742                "-a",
743                "pimalaya/posteo",
744                "-w"
745            ],
746        );
747        assert_eq!(
748            KeyringProvider::KwalletQuery.read_command(None, entry),
749            ["kwallet-query", "-r", "pimalaya/posteo", "kdewallet"],
750        );
751    }
752
753    #[test]
754    fn keyring_read_command_namespaces_the_entry_when_a_service_is_given() {
755        let (service, account) = (Some("ortie"), "acme");
756        assert_eq!(
757            KeyringProvider::Pass.read_command(service, account),
758            ["pass", "show", "ortie/acme"],
759        );
760        assert_eq!(
761            KeyringProvider::SecretTool.read_command(service, account),
762            [
763                "secret-tool",
764                "lookup",
765                "service",
766                "ortie",
767                "account",
768                "acme"
769            ],
770        );
771        assert_eq!(
772            KeyringProvider::Security.read_command(service, account),
773            [
774                "security",
775                "find-generic-password",
776                "-s",
777                "ortie",
778                "-a",
779                "acme",
780                "-w"
781            ],
782        );
783    }
784
785    #[test]
786    fn keyring_write_command_names_a_security_service_without_a_namespace() {
787        assert_eq!(
788            KeyringProvider::Security.write_command(None, "acme"),
789            "security add-generic-password -U -s acme -a acme -w \"$(cat)\"",
790        );
791        assert_eq!(
792            KeyringProvider::Security.write_command(Some("ortie"), "acme"),
793            "security add-generic-password -U -s ortie -a acme -w \"$(cat)\"",
794        );
795    }
796
797    #[test]
798    fn secret_tool_lookup_reads_the_named_attribute() {
799        assert_eq!(
800            secret_tool_lookup("Title", "disroot"),
801            ["secret-tool", "lookup", "Title", "disroot"],
802        );
803        assert_eq!(
804            KeyringProvider::SecretTool.read_command(None, "disroot"),
805            secret_tool_lookup("account", "disroot"),
806        );
807    }
808
809    #[test]
810    fn broker_read_command_targets_the_account_per_broker() {
811        assert_eq!(
812            TokenBroker::Ortie.read_command("acme"),
813            ["ortie", "token", "show", "-a", "acme"],
814        );
815        assert_eq!(
816            TokenBroker::Pizauth.read_command("acme"),
817            ["pizauth", "show", "acme"]
818        );
819        assert_eq!(
820            TokenBroker::Oama.read_command("me@acme.test"),
821            ["oama", "access", "me@acme.test"],
822        );
823    }
824
825    #[test]
826    fn available_lists_are_non_empty() {
827        assert!(!TokenBroker::available().is_empty());
828        if cfg!(unix) {
829            assert!(!KeyringProvider::available().is_empty());
830        }
831    }
832
833    /// Whatever this machine has installed, the two groups never
834    /// interleave: once the list reaches an entry that is not there,
835    /// nothing installed follows.
836    #[test]
837    fn available_lists_the_installed_entries_first() {
838        let providers = KeyringProvider::available();
839        if let Some(missing) = providers.iter().position(|p| !p.installed()) {
840            assert!(providers[missing..].iter().all(|p| !p.installed()));
841        }
842
843        let brokers = TokenBroker::available();
844        if let Some(missing) = brokers.iter().position(|b| !b.installed()) {
845            assert!(brokers[missing..].iter().all(|b| !b.installed()));
846        }
847    }
848
849    #[test]
850    fn a_program_is_looked_up_in_every_path_entry() {
851        let dir = env::temp_dir().join("pimalaya-cli-keyring-path");
852        fs::create_dir_all(&dir).expect("create the PATH directory");
853        let program = "pimalaya-cli-test-provider";
854        fs::write(dir.join(program), "").expect("write the program");
855
856        let paths =
857            env::join_paths([PathBuf::from("/nonexistent"), dir.clone()]).expect("join the paths");
858
859        assert!(found_in(&paths, program));
860        assert!(!found_in(&paths, "pimalaya-cli-test-missing"));
861
862        // A `PATH` that does not hold the directory does not find it,
863        // which is what the labels report.
864        let elsewhere = env::join_paths([PathBuf::from("/nonexistent")]).expect("join the paths");
865        assert!(!found_in(&elsewhere, program));
866
867        fs::remove_file(dir.join(program)).expect("remove the program");
868    }
869
870    #[test]
871    fn an_entry_that_is_not_there_says_so() {
872        assert_eq!(
873            label("pass (password store)", true),
874            "pass (password store)"
875        );
876        assert_eq!(
877            label("pass (password store)", false),
878            "pass (password store), not found on PATH",
879        );
880    }
881
882    #[test]
883    fn an_empty_command_is_not_installed() {
884        assert!(!installed(&[]));
885    }
886
887    #[test]
888    fn a_store_command_writes_the_entry_its_read_command_reads() {
889        let key = "acme-imap";
890
891        assert_eq!(
892            KeyringProvider::SecretTool.store_command(key),
893            [
894                "secret-tool",
895                "store",
896                "--label",
897                "acme-imap",
898                "account",
899                "acme-imap"
900            ],
901        );
902        assert_eq!(
903            KeyringProvider::KwalletQuery.store_command(key),
904            ["kwallet-query", "-w", "acme-imap", "kdewallet"],
905        );
906        assert_eq!(
907            KeyringProvider::Pass.store_command(key),
908            ["pass", "insert", "-m", "-f", "acme-imap"],
909        );
910
911        // The keychain takes its command line, secret included, on stdin.
912        assert_eq!(
913            KeyringProvider::Security.store_command(key),
914            ["security", "-i"]
915        );
916    }
917
918    #[test]
919    fn a_keychain_line_quotes_and_escapes_its_values() {
920        let line = security_line("acme \"imap\"", &SecretString::from(r#"a"b\c d"#))
921            .expect("build the line");
922
923        assert_eq!(
924            line.expose_secret(),
925            "add-generic-password -U -s \"acme \\\"imap\\\"\" -a \"acme \\\"imap\\\"\" \
926             -w \"a\\\"b\\\\c d\"\n",
927        );
928    }
929
930    #[test]
931    fn a_keychain_line_refuses_a_line_break() {
932        let secret = SecretString::from("a\nadd-generic-password");
933        let err = security_line("acme", &secret).expect_err("refuse the secret");
934        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
935
936        let secret = SecretString::from("abc");
937        let err = security_line("acme\r", &secret).expect_err("refuse the key");
938        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
939    }
940
941    #[test]
942    fn an_endpoint_is_on_its_domain_and_subdomains_only() {
943        let domain = "example.org";
944
945        assert!(!off_domain("imaps://imap.example.org:993", domain));
946        assert!(!off_domain("https://example.org/jmap", domain));
947        assert!(!off_domain("imaps://IMAP.Example.ORG:993", domain));
948        assert!(!off_domain("smtp://mail.example.org.:587", "Example.org"));
949        assert!(!off_domain("imap.example.org:143", domain));
950
951        assert!(off_domain("imaps://imap.example.net:993", domain));
952        assert!(off_domain("https://example.org.example.net/", domain));
953        assert!(off_domain("https://notexample.org", domain));
954        assert!(off_domain(
955            "imaps://imap.example.org@example.net:993",
956            domain
957        ));
958        assert!(off_domain("https://[2001:db8::1]:443/example.org", domain));
959        assert!(off_domain("https://example.net?example.org", domain));
960    }
961
962    #[test]
963    fn a_label_reads_as_a_noun_its_initialisms_kept() {
964        assert_eq!(noun("Password"), "password");
965        assert_eq!(noun("SMTP password"), "SMTP password");
966        assert_eq!(noun("API token"), "API token");
967        assert_eq!(noun("P"), "P");
968    }
969}