1use keyring::Entry;
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31
32pub const DEFAULT_SERVICE: &str = "car";
44
45pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
66 if let Ok(v) = std::env::var(env_var) {
67 if !v.is_empty() {
68 return Some(v);
69 }
70 }
71 let store = SecretStore::new();
72 if !store.is_available() {
73 return None;
74 }
75 let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
76 match store.get(&secret_ref) {
77 Ok(v) if !v.is_empty() => {
78 tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
79 Some(v)
80 }
81 Ok(_) => None, Err(SecretError::NotFound { .. }) => None,
83 Err(e) => {
84 tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
85 None
86 }
87 }
88}
89
90#[derive(Debug, Error)]
92pub enum SecretError {
93 #[error("secret store unavailable: {0}")]
96 Unavailable(String),
97
98 #[error("no entry for service={service:?} key={key:?}")]
100 NotFound { service: String, key: String },
101
102 #[error("secret store error: {0}")]
105 Backend(String),
106
107 #[error("stored value is not valid JSON: {0}")]
109 InvalidJson(String),
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct SecretStatus {
115 pub service: String,
116 pub key: String,
117 pub exists: bool,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AvailabilityCheck {
127 pub available: bool,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub reason: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134pub struct SecretRef {
135 pub service: String,
136 pub key: String,
137}
138
139impl SecretRef {
140 pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
141 Self {
142 service: service.into(),
143 key: key.into(),
144 }
145 }
146
147 pub fn with_default_service(key: impl Into<String>) -> Self {
148 Self {
149 service: DEFAULT_SERVICE.to_string(),
150 key: key.into(),
151 }
152 }
153}
154
155#[derive(Debug, Default, Clone, Copy)]
161pub struct SecretStore;
162
163impl SecretStore {
164 pub fn new() -> Self {
165 Self
166 }
167
168 pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
188 platform_put(self, r, value)
189 }
190
191 pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
193 let s = serde_json::to_string(value)
194 .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
195 self.put(r, &s)
196 }
197
198 pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
206 platform_get(self, r)
207 }
208
209 pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
211 let raw = self.get(r)?;
212 serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
213 }
214
215 pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
222 platform_delete(self, r)
223 }
224
225 pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
230 platform_status(self, r)
231 }
232
233 const PROBE_SERVICE: &'static str = "car-internal";
239 const PROBE_KEY: &'static str = "__availability_probe__";
240
241 pub fn is_available(&self) -> bool {
256 self.availability().available
257 }
258
259 pub fn availability(&self) -> AvailabilityCheck {
265 let probe = SecretRef::new(Self::PROBE_SERVICE, Self::PROBE_KEY);
269 match self.entry(&probe) {
270 Ok(entry) => match entry.get_password() {
271 Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
272 available: true,
273 reason: None,
274 },
275 Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
276 available: false,
277 reason: Some(format!("platform failure: {e}")),
278 },
279 Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
280 available: false,
281 reason: Some(format!("no storage access: {e}")),
282 },
283 Err(_) => AvailabilityCheck {
290 available: true,
291 reason: None,
292 },
293 },
294 Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
295 available: false,
296 reason: Some(reason),
297 },
298 Err(other) => AvailabilityCheck {
299 available: false,
300 reason: Some(other.to_string()),
301 },
302 }
303 }
304
305 fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
306 Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
307 }
308}
309
310#[cfg(target_os = "macos")]
323fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
324 mac_put_via_security_cli(&r.service, &r.key, value)
325}
326
327#[cfg(not(target_os = "macos"))]
328fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
329 let entry = store.entry(r)?;
330 entry
331 .set_password(value)
332 .map_err(|e| classify(e, "set_password"))
333}
334
335#[cfg(target_os = "macos")]
336fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
337 mac_get_via_security_cli(r)
338}
339
340#[cfg(not(target_os = "macos"))]
341fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
342 let entry = store.entry(r)?;
343 match entry.get_password() {
344 Ok(v) => Ok(v),
345 Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
346 service: r.service.clone(),
347 key: r.key.clone(),
348 }),
349 Err(other) => Err(classify(other, "get_password")),
350 }
351}
352
353#[cfg(target_os = "macos")]
354fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
355 mac_delete_via_security_cli(r)
356}
357
358#[cfg(not(target_os = "macos"))]
359fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
360 let entry = store.entry(r)?;
361 match entry.delete_credential() {
362 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
363 Err(other) => Err(classify(other, "delete_credential")),
364 }
365}
366
367#[cfg(target_os = "macos")]
368fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
369 mac_status_via_security_cli(r)
370}
371
372#[cfg(not(target_os = "macos"))]
373fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
374 let entry = store.entry(r)?;
375 let exists = match entry.get_password() {
376 Ok(_) => true,
377 Err(keyring::Error::NoEntry) => false,
378 Err(other) => return Err(classify(other, "status")),
379 };
380 Ok(SecretStatus {
381 service: r.service.clone(),
382 key: r.key.clone(),
383 exists,
384 })
385}
386
387#[cfg(target_os = "macos")]
404fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
405 mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
406}
407
408#[cfg(target_os = "macos")]
409fn mac_put_via_security_cli_with(
410 service: &str,
411 account: &str,
412 value: &str,
413 cli: &impl SecurityCli,
414) -> Result<(), SecretError> {
415 let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);
419
420 let output = cli
421 .output(&[
422 "add-generic-password",
423 "-U", "-A", "-s",
426 service,
427 "-a",
428 account,
429 "-w",
430 value,
431 ])
432 .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
433 if output.success {
434 return Ok(());
435 }
436 Err(security_cli_backend_error("add-generic-password", output))
437}
438
439#[cfg(target_os = "macos")]
440const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
441
442#[cfg(target_os = "macos")]
443#[derive(Debug)]
444struct SecurityCliOutput {
445 success: bool,
446 code: Option<i32>,
447 stdout: Vec<u8>,
448 stderr: Vec<u8>,
449}
450
451#[cfg(target_os = "macos")]
452trait SecurityCli {
453 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
454}
455
456#[cfg(target_os = "macos")]
457struct SystemSecurityCli;
458
459#[cfg(target_os = "macos")]
460impl SecurityCli for SystemSecurityCli {
461 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
462 use std::process::{Command, Stdio};
463 let output = Command::new("/usr/bin/security")
464 .args(args)
465 .stdout(Stdio::piped())
466 .stderr(Stdio::piped())
467 .output()?;
468 Ok(SecurityCliOutput {
469 success: output.status.success(),
470 code: output.status.code(),
471 stdout: output.stdout,
472 stderr: output.stderr,
473 })
474 }
475}
476
477#[cfg(target_os = "macos")]
485fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
486 mac_get_via_security_cli_with(r, &SystemSecurityCli)
487}
488
489#[cfg(target_os = "macos")]
490fn mac_get_via_security_cli_with(
491 r: &SecretRef,
492 cli: &impl SecurityCli,
493) -> Result<String, SecretError> {
494 let output = cli
495 .output(&[
496 "find-generic-password",
497 "-s",
498 &r.service,
499 "-a",
500 &r.key,
501 "-g",
502 ])
503 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
504 if !output.success {
505 return security_cli_not_found_or_backend("find-generic-password", r, output);
506 }
507 mac_parse_security_cli_password(&output)
508}
509
510#[cfg(target_os = "macos")]
511fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
512 let line = mac_security_cli_text(&output.stderr, "stderr")?
513 .lines()
514 .find(|line| line.starts_with("password:"))
515 .or_else(|| {
516 mac_security_cli_text(&output.stdout, "stdout")
517 .ok()
518 .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
519 })
520 .ok_or_else(|| {
521 SecretError::Backend(
522 "/usr/bin/security find-generic-password -g did not print a password line"
523 .to_string(),
524 )
525 })?;
526
527 let payload = line
528 .strip_prefix("password:")
529 .expect("password line prefix was checked")
530 .trim_start();
531
532 if payload.is_empty() {
533 return Ok(String::new());
534 }
535
536 let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
537 mac_decode_security_cli_hex_password(hex_and_preview)?
538 } else {
539 mac_decode_security_cli_quoted_password(payload)?
540 };
541
542 String::from_utf8(bytes).map_err(|e| {
543 SecretError::Backend(format!(
544 "/usr/bin/security find-generic-password password was not valid utf-8: {}",
545 e
546 ))
547 })
548}
549
550#[cfg(target_os = "macos")]
551fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
552 std::str::from_utf8(bytes).map_err(|e| {
553 SecretError::Backend(format!(
554 "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
555 ))
556 })
557}
558
559#[cfg(target_os = "macos")]
560fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
561 let hex: String = hex_and_preview
562 .chars()
563 .take_while(|c| c.is_ascii_hexdigit())
564 .collect();
565 if hex.is_empty() || hex.len() % 2 != 0 {
566 return Err(SecretError::Backend(format!(
567 "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
568 )));
569 }
570
571 (0..hex.len())
572 .step_by(2)
573 .map(|i| {
574 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
575 SecretError::Backend(format!(
576 "/usr/bin/security find-generic-password printed invalid password hex: {e}"
577 ))
578 })
579 })
580 .collect()
581}
582
583#[cfg(target_os = "macos")]
584fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
585 let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
586 match quoted {
587 Some(value) => Ok(value.as_bytes().to_vec()),
588 None => Err(SecretError::Backend(
589 "/usr/bin/security find-generic-password printed an unrecognized password line"
590 .to_string(),
591 )),
592 }
593}
594
595#[cfg(target_os = "macos")]
596fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
597 mac_status_via_security_cli_with(r, &SystemSecurityCli)
598}
599
600#[cfg(target_os = "macos")]
601fn mac_status_via_security_cli_with(
602 r: &SecretRef,
603 cli: &impl SecurityCli,
604) -> Result<SecretStatus, SecretError> {
605 let exists = mac_exists_via_security_cli_with(r, cli)?;
606 Ok(SecretStatus {
607 service: r.service.clone(),
608 key: r.key.clone(),
609 exists,
610 })
611}
612
613#[cfg(target_os = "macos")]
618fn mac_exists_via_security_cli_with(
619 r: &SecretRef,
620 cli: &impl SecurityCli,
621) -> Result<bool, SecretError> {
622 let output = cli
623 .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
624 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
625 if output.success {
626 return Ok(true);
627 }
628 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
629 return Ok(false);
630 }
631 Err(security_cli_backend_error("find-generic-password", output))
632}
633
634#[cfg(target_os = "macos")]
635fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
636 mac_delete_via_security_cli_with(r, &SystemSecurityCli)
637}
638
639#[cfg(target_os = "macos")]
642fn mac_delete_via_security_cli_with(
643 r: &SecretRef,
644 cli: &impl SecurityCli,
645) -> Result<(), SecretError> {
646 let output = cli
647 .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
648 .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
649 if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
650 return Ok(());
651 }
652 Err(security_cli_backend_error(
653 "delete-generic-password",
654 output,
655 ))
656}
657
658#[cfg(target_os = "macos")]
659fn security_cli_not_found_or_backend<T>(
660 command: &str,
661 r: &SecretRef,
662 output: SecurityCliOutput,
663) -> Result<T, SecretError> {
664 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
665 return Err(SecretError::NotFound {
666 service: r.service.clone(),
667 key: r.key.clone(),
668 });
669 }
670 Err(security_cli_backend_error(command, output))
671}
672
673#[cfg(target_os = "macos")]
674fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
675 SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
676}
677
678#[cfg(target_os = "macos")]
679fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
680 let stderr = String::from_utf8_lossy(&output.stderr);
681 SecretError::Backend(format!(
682 "/usr/bin/security {command} failed: code={} {}",
683 output.code.unwrap_or(-1),
684 stderr.trim()
685 ))
686}
687
688fn classify(e: keyring::Error, op: &str) -> SecretError {
690 use keyring::Error as K;
691 match e {
692 K::NoEntry => SecretError::NotFound {
693 service: String::new(),
694 key: String::new(),
695 },
696 K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
697 K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
698 K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
699 other => SecretError::Backend(format!("{}: {}", op, other)),
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use serde::{Deserialize, Serialize};
707
708 fn test_service() -> String {
713 format!(
714 "car-secrets-tests-{}-{}",
715 std::process::id(),
716 std::time::SystemTime::now()
719 .duration_since(std::time::UNIX_EPOCH)
720 .map(|d| d.as_nanos())
721 .unwrap_or(0)
722 )
723 }
724
725 fn skip_if_unavailable() -> bool {
726 !SecretStore::new().is_available()
727 }
728
729 #[cfg(target_os = "macos")]
730 struct FakeSecurityCli {
731 outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
732 calls: std::cell::RefCell<Vec<Vec<String>>>,
733 }
734
735 #[cfg(target_os = "macos")]
736 impl FakeSecurityCli {
737 fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
738 Self {
739 outputs: std::cell::RefCell::new(outputs.into()),
740 calls: std::cell::RefCell::new(Vec::new()),
741 }
742 }
743
744 fn calls(&self) -> Vec<Vec<String>> {
745 self.calls.borrow().clone()
746 }
747 }
748
749 #[cfg(target_os = "macos")]
750 impl SecurityCli for FakeSecurityCli {
751 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
752 self.calls
753 .borrow_mut()
754 .push(args.iter().map(|arg| (*arg).to_string()).collect());
755 self.outputs
756 .borrow_mut()
757 .pop_front()
758 .expect("missing fake security output")
759 }
760 }
761
762 #[cfg(target_os = "macos")]
763 fn security_output(
764 code: i32,
765 stdout: impl Into<Vec<u8>>,
766 stderr: impl Into<Vec<u8>>,
767 ) -> std::io::Result<SecurityCliOutput> {
768 Ok(SecurityCliOutput {
769 success: code == 0,
770 code: Some(code),
771 stdout: stdout.into(),
772 stderr: stderr.into(),
773 })
774 }
775
776 #[cfg(target_os = "macos")]
777 fn args(values: &[&str]) -> Vec<String> {
778 values.iter().map(|value| (*value).to_string()).collect()
779 }
780
781 #[cfg(target_os = "macos")]
782 fn assert_backend_contains(err: SecretError, expected: &str) {
783 match err {
784 SecretError::Backend(message) => assert!(
785 message.contains(expected),
786 "expected backend error to contain {expected:?}, got {message:?}"
787 ),
788 other => panic!("expected Backend, got {:?}", other),
789 }
790 }
791
792 #[test]
793 fn roundtrip_string() {
794 if skip_if_unavailable() {
795 eprintln!("skipping: no secret store backend available");
796 return;
797 }
798 let store = SecretStore::new();
799 let svc = test_service();
800 let r = SecretRef::new(&svc, "roundtrip");
801 store.put(&r, "hello world").unwrap();
802 assert_eq!(store.get(&r).unwrap(), "hello world");
803 assert!(store.status(&r).unwrap().exists);
804 store.delete(&r).unwrap();
805 assert!(!store.status(&r).unwrap().exists);
806 }
807
808 #[test]
809 fn roundtrip_string_with_trailing_newline() {
810 if skip_if_unavailable() {
811 eprintln!("skipping: no secret store backend available");
812 return;
813 }
814 let store = SecretStore::new();
815 let svc = test_service();
816 let r = SecretRef::new(&svc, "roundtrip-newline");
817 let value = "abc\n";
818 store.put(&r, value).unwrap();
819 assert_eq!(store.get(&r).unwrap(), value);
820 store.delete(&r).unwrap();
821 }
822
823 #[test]
824 fn get_missing_returns_not_found() {
825 if skip_if_unavailable() {
826 return;
827 }
828 let store = SecretStore::new();
829 let r = SecretRef::new(test_service(), "never_written");
830 match store.get(&r) {
831 Err(SecretError::NotFound { .. }) => (),
832 other => panic!("expected NotFound, got {:?}", other),
833 }
834 }
835
836 #[test]
837 fn delete_missing_is_idempotent() {
838 if skip_if_unavailable() {
839 return;
840 }
841 let store = SecretStore::new();
842 let r = SecretRef::new(test_service(), "missing");
843 store.delete(&r).unwrap();
845 store.delete(&r).unwrap();
846 }
847
848 #[test]
849 fn json_roundtrip() {
850 if skip_if_unavailable() {
851 return;
852 }
853 #[derive(Serialize, Deserialize, PartialEq, Debug)]
854 struct Session {
855 cookies: Vec<String>,
856 expires_at: i64,
857 }
858 let store = SecretStore::new();
859 let svc = test_service();
860 let r = SecretRef::new(&svc, "session");
861 let s = Session {
862 cookies: vec!["a=1".into(), "b=2".into()],
863 expires_at: 1_700_000_000,
864 };
865 store.put_json(&r, &s).unwrap();
866 let back: Session = store.get_json(&r).unwrap();
867 assert_eq!(back, s);
868 store.delete(&r).unwrap();
869 }
870
871 #[test]
872 fn status_no_leak() {
873 if skip_if_unavailable() {
874 return;
875 }
876 let store = SecretStore::new();
877 let r = SecretRef::new(test_service(), "status");
878 store.put(&r, "secret-payload").unwrap();
879 let st = store.status(&r).unwrap();
880 let encoded = serde_json::to_string(&st).unwrap();
882 assert!(!encoded.contains("secret-payload"));
883 store.delete(&r).unwrap();
884 }
885
886 #[cfg(target_os = "macos")]
887 #[test]
888 fn mac_get_uses_security_cli_and_maps_success() {
889 let cli = FakeSecurityCli::new(vec![security_output(
890 0,
891 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
892 b"password: \"secret\"\n",
893 )]);
894 let r = SecretRef::new("svc", "key");
895
896 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
897 assert_eq!(
898 cli.calls(),
899 vec![args(&[
900 "find-generic-password",
901 "-s",
902 "svc",
903 "-a",
904 "key",
905 "-g"
906 ])]
907 );
908 }
909
910 #[cfg(target_os = "macos")]
911 #[test]
912 fn mac_get_decodes_hex_password_output_with_trailing_newline() {
913 let cli = FakeSecurityCli::new(vec![security_output(
914 0,
915 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
916 b"password: 0x6162630A \"abc\\012\"\n",
917 )]);
918 let r = SecretRef::new("svc", "key");
919
920 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
921 assert_eq!(
922 cli.calls(),
923 vec![args(&[
924 "find-generic-password",
925 "-s",
926 "svc",
927 "-a",
928 "key",
929 "-g"
930 ])]
931 );
932 }
933
934 #[cfg(target_os = "macos")]
935 #[test]
936 fn mac_get_maps_not_found_and_backend_errors_without_fallback() {
937 let r = SecretRef::new("svc", "missing");
938 let cli = FakeSecurityCli::new(vec![security_output(
939 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
940 b"",
941 b"The specified item could not be found in the keychain.\n",
942 )]);
943
944 match mac_get_via_security_cli_with(&r, &cli) {
945 Err(SecretError::NotFound { service, key }) => {
946 assert_eq!(service, "svc");
947 assert_eq!(key, "missing");
948 }
949 other => panic!("expected NotFound, got {:?}", other),
950 }
951 assert_eq!(cli.calls().len(), 1);
952
953 let cli = FakeSecurityCli::new(vec![security_output(
954 51,
955 b"",
956 b"User interaction is not allowed.\n",
957 )]);
958 let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
959 assert_backend_contains(err, "code=51 User interaction is not allowed.");
960 assert_eq!(cli.calls().len(), 1);
961 }
962
963 #[cfg(target_os = "macos")]
964 #[test]
965 fn mac_status_uses_security_cli_and_maps_results() {
966 let r = SecretRef::new("svc", "key");
967 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
968
969 let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
970 assert!(status.exists);
971 assert_eq!(
972 cli.calls(),
973 vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
974 );
975
976 let cli = FakeSecurityCli::new(vec![security_output(
977 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
978 b"",
979 b"The specified item could not be found in the keychain.\n",
980 )]);
981 assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
982
983 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
984 let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
985 assert_backend_contains(err, "code=128 auth denied");
986 }
987
988 #[cfg(target_os = "macos")]
989 #[test]
990 fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
991 let cli = FakeSecurityCli::new(vec![
998 security_output(
1000 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
1001 b"",
1002 b"The specified item could not be found in the keychain.\n",
1003 ),
1004 security_output(0, b"", b""),
1006 ]);
1007
1008 mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
1009
1010 assert_eq!(
1011 cli.calls(),
1012 vec![
1013 args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
1014 args(&[
1015 "add-generic-password",
1016 "-U",
1017 "-A",
1018 "-s",
1019 "svc",
1020 "-a",
1021 "key",
1022 "-w",
1023 "secret",
1024 ]),
1025 ]
1026 );
1027 }
1028
1029 #[cfg(target_os = "macos")]
1030 #[test]
1031 fn mac_put_ignores_pre_delete_failure_and_still_adds() {
1032 let cli = FakeSecurityCli::new(vec![
1037 security_output(128, b"", b"some weird backend error\n"),
1038 security_output(0, b"", b""),
1039 ]);
1040
1041 mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
1042
1043 assert_eq!(cli.calls().len(), 2);
1044 assert_eq!(
1045 cli.calls()[1],
1046 args(&[
1047 "add-generic-password",
1048 "-U",
1049 "-A",
1050 "-s",
1051 "svc",
1052 "-a",
1053 "key",
1054 "-w",
1055 "secret",
1056 ])
1057 );
1058 }
1059
1060 #[cfg(target_os = "macos")]
1061 #[test]
1062 fn mac_put_surfaces_add_failure_as_backend_error() {
1063 let cli = FakeSecurityCli::new(vec![
1064 security_output(0, b"", b""),
1065 security_output(51, b"", b"User interaction is not allowed.\n"),
1066 ]);
1067
1068 let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
1069 assert_backend_contains(err, "code=51 User interaction is not allowed.");
1070 }
1071
1072 #[cfg(target_os = "macos")]
1073 #[test]
1074 fn mac_delete_uses_security_cli_and_maps_results() {
1075 let r = SecretRef::new("svc", "key");
1076 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
1077
1078 mac_delete_via_security_cli_with(&r, &cli).unwrap();
1079 assert_eq!(
1080 cli.calls(),
1081 vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
1082 );
1083
1084 let cli = FakeSecurityCli::new(vec![security_output(
1085 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
1086 b"",
1087 b"The specified item could not be found in the keychain.\n",
1088 )]);
1089 mac_delete_via_security_cli_with(&r, &cli).unwrap();
1090
1091 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
1092 let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
1093 assert_backend_contains(err, "code=128 auth denied");
1094 }
1095}