1use 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#[cfg(windows)]
42const WINDOWS_EXTENSIONS: [&str; 3] = [".exe", ".cmd", ".bat"];
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum KeyringProvider {
47 SecretTool,
49 KwalletQuery,
51 Security,
53 Pass,
55}
56
57impl KeyringProvider {
58 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 providers.sort_by_key(|provider| !provider.installed());
82
83 providers
84 }
85
86 pub fn installed(self) -> bool {
93 installed(&self.read_command(None, ""))
94 }
95
96 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 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 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 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
249pub enum TokenBroker {
250 Ortie,
252 Pizauth,
254 Oama,
256}
257
258impl TokenBroker {
259 pub fn available() -> Vec<Self> {
263 let mut brokers = vec![Self::Ortie, Self::Pizauth, Self::Oama];
264
265 brokers.sort_by_key(|broker| !broker.installed());
267
268 brokers
269 }
270
271 pub fn installed(self) -> bool {
275 installed(&self.read_command(""))
276 }
277
278 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 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
304fn 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
321fn found_in(paths: &OsStr, program: &str) -> bool {
326 env::split_paths(paths).any(|dir| found_at(&dir, program))
327}
328
329#[cfg(not(windows))]
331fn found_at(dir: &Path, program: &str) -> bool {
332 dir.join(program).is_file()
333}
334
335#[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
348fn argv<const N: usize>(parts: [&str; N]) -> Vec<String> {
350 parts.iter().map(|part| part.to_string()).collect()
351}
352
353fn secret_tool_lookup(attribute: &str, key: &str) -> Vec<String> {
358 argv(["secret-tool", "lookup", attribute, key])
359}
360
361fn path(service: Option<&str>, key: &str) -> String {
364 match service {
365 Some(service) => format!("{service}/{key}"),
366 None => key.to_owned(),
367 }
368}
369
370fn 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 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
397fn 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
412fn 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
424fn 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
438fn 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
466pub enum SecretChoice {
473 Command(Vec<String>),
476 Shell(String),
479 Raw(SecretString),
481}
482
483#[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
520fn 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#[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
548pub 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
562pub 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
588pub 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
611pub 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
647fn 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
660fn 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 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 #[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 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 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}