1#[cfg(not(target_os = "macos"))]
33use keyring::Entry;
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37pub mod secure_path;
38pub use secure_path::{
39 atomic_replace_private_file, create_private_file, create_private_file_with_failure_injector,
40 ensure_private_dir, ensure_private_dir_with_failure_injector, harden_owner_only,
41 harden_owner_only_fallible, harden_private_tree, open_private_append,
42 open_private_append_with_failure_injector, open_private_read, open_private_truncate,
43 revalidate_private_file, revalidate_private_path, PrivatePathDurabilityFailureInjector,
44 PrivatePathDurabilityFailurePoint, PrivateTree, PrivateTreePolicy, PrivateTreeReport,
45};
46
47pub const DEFAULT_SERVICE: &str = "car";
59
60pub const OPENROUTER_OAUTH_KEY: &str = "OPENROUTER_OAUTH_API_KEY";
65pub const PARSLEE_ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
66pub const PARSLEE_REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
67pub const PARSLEE_EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
68pub const PARSLEE_API_BASE_KEY: &str = "PARSLEE_API_BASE";
69pub const PARSLEE_ACCOUNTS_KEY: &str = "PARSLEE_ACCOUNTS";
70pub const PARSLEE_TOKENS_PREFIX: &str = "PARSLEE_TOKENS_";
71pub const PARSLEE_AUTH_GENERATION_KEY: &str = "PARSLEE_AUTH_GENERATION";
72pub const PARSLEE_AUTH_COMPLETION_KEY: &str = "PARSLEE_AUTH_COMPLETION";
73pub const PARSLEE_ACTIVE_ACCOUNT_ID_KEY: &str = "PARSLEE_ACTIVE_ACCOUNT_ID";
74pub const PARSLEE_AUTH_STATE_V2_KEY: &str = "PARSLEE_AUTH_STATE_V2";
75
76fn is_private_chunk_derivative(key: &str, root: &str) -> bool {
77 key.strip_prefix(root)
78 .is_some_and(|suffix| suffix.starts_with("#chunk"))
79}
80
81pub fn is_daemon_private_secret(service: &str, key: &str) -> bool {
82 service == DEFAULT_SERVICE
83 && (matches!(
84 key,
85 OPENROUTER_OAUTH_KEY
86 | PARSLEE_ACCESS_TOKEN_KEY
87 | PARSLEE_REFRESH_TOKEN_KEY
88 | PARSLEE_EXPIRES_AT_KEY
89 | PARSLEE_API_BASE_KEY
90 | PARSLEE_ACCOUNTS_KEY
91 | PARSLEE_AUTH_GENERATION_KEY
92 | PARSLEE_AUTH_COMPLETION_KEY
93 | PARSLEE_ACTIVE_ACCOUNT_ID_KEY
94 | PARSLEE_AUTH_STATE_V2_KEY
95 ) || key.starts_with(PARSLEE_TOKENS_PREFIX)
96 || [
97 OPENROUTER_OAUTH_KEY,
98 PARSLEE_ACCESS_TOKEN_KEY,
99 PARSLEE_REFRESH_TOKEN_KEY,
100 PARSLEE_EXPIRES_AT_KEY,
101 PARSLEE_API_BASE_KEY,
102 PARSLEE_ACCOUNTS_KEY,
103 PARSLEE_AUTH_GENERATION_KEY,
104 PARSLEE_AUTH_COMPLETION_KEY,
105 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
106 PARSLEE_AUTH_STATE_V2_KEY,
107 ]
108 .iter()
109 .any(|root| is_private_chunk_derivative(key, root)))
110}
111
112pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
133 if let Ok(v) = std::env::var(env_var) {
134 if !v.is_empty() {
135 return Some(v);
136 }
137 }
138 let store = SecretStore::new();
139 if !store.is_available() {
140 return None;
141 }
142 let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
143 match store.get(&secret_ref) {
144 Ok(v) if !v.is_empty() => {
145 tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
146 Some(v)
147 }
148 Ok(_) => None, Err(SecretError::NotFound { .. }) => None,
150 Err(e) => {
151 tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
152 None
153 }
154 }
155}
156
157#[derive(Debug, Error)]
159pub enum SecretError {
160 #[error("secret store unavailable: {0}")]
163 Unavailable(String),
164
165 #[error("no entry for service={service:?} key={key:?}")]
167 NotFound { service: String, key: String },
168
169 #[error("secret store access denied: {message}")]
171 AccessDenied { message: String },
172
173 #[error("secret store access cancelled: {message}")]
175 UserCancelled { message: String },
176
177 #[error("secret store helper timed out during {operation}")]
179 HelperTimedOut { operation: String },
180
181 #[error("secret store error: {0}")]
184 Backend(String),
185
186 #[error("stored value is not valid JSON: {0}")]
188 InvalidJson(String),
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct SecretStatus {
194 pub service: String,
195 pub key: String,
196 pub exists: bool,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct AvailabilityCheck {
206 pub available: bool,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub reason: Option<String>,
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
216pub struct SecretStoreActivity {
217 pub get_attempts: u64,
218 pub status_attempts: u64,
219 pub availability_attempts: u64,
220 pub write_attempts: u64,
221 pub delete_attempts: u64,
222}
223
224static GET_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
225static STATUS_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
226static AVAILABILITY_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
227static WRITE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
228static DELETE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
229
230pub fn secret_store_activity() -> SecretStoreActivity {
232 use std::sync::atomic::Ordering;
233
234 SecretStoreActivity {
235 get_attempts: GET_ATTEMPTS.load(Ordering::Relaxed),
236 status_attempts: STATUS_ATTEMPTS.load(Ordering::Relaxed),
237 availability_attempts: AVAILABILITY_ATTEMPTS.load(Ordering::Relaxed),
238 write_attempts: WRITE_ATTEMPTS.load(Ordering::Relaxed),
239 delete_attempts: DELETE_ATTEMPTS.load(Ordering::Relaxed),
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
245pub struct SecretRef {
246 pub service: String,
247 pub key: String,
248}
249
250impl SecretRef {
251 pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
252 Self {
253 service: service.into(),
254 key: key.into(),
255 }
256 }
257
258 pub fn with_default_service(key: impl Into<String>) -> Self {
259 Self {
260 service: DEFAULT_SERVICE.to_string(),
261 key: key.into(),
262 }
263 }
264}
265
266#[derive(Debug, Default, Clone, Copy)]
272pub struct SecretStore;
273
274impl SecretStore {
275 pub fn new() -> Self {
276 Self
277 }
278
279 pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
299 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
300 platform_put(self, r, value)
301 }
302
303 pub fn publish(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
320 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
321 platform_publish(self, r, value)
322 }
323
324 pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
326 let s = serde_json::to_string(value)
327 .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
328 self.put(r, &s)
329 }
330
331 pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
339 GET_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
340 platform_get(self, r)
341 }
342
343 pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
345 let raw = self.get(r)?;
346 serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
347 }
348
349 pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
356 DELETE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
357 platform_delete(self, r)
358 }
359
360 pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
365 STATUS_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
366 platform_status(self, r)
367 }
368
369 const PROBE_SERVICE: &'static str = "car-internal";
375 const PROBE_KEY: &'static str = "__availability_probe__";
376
377 pub fn is_available(&self) -> bool {
394 self.availability().available
395 }
396
397 pub fn availability(&self) -> AvailabilityCheck {
403 AVAILABILITY_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
404 if file_backend_dir().is_some() {
410 return AvailabilityCheck {
411 available: true,
412 reason: None,
413 };
414 }
415 platform_availability(self)
416 }
417
418 #[cfg(not(target_os = "macos"))]
431 fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
432 Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
433 }
434}
435
436fn file_backend_dir() -> Option<std::path::PathBuf> {
472 if !cfg!(debug_assertions) {
475 return None;
476 }
477 match std::env::var_os("CAR_SECRETS_FILE_DIR") {
478 Some(d) if !d.is_empty() => {
479 static WARNED: std::sync::Once = std::sync::Once::new();
482 WARNED.call_once(|| {
483 tracing::warn!(
484 "CAR_SECRETS_FILE_DIR set — secrets are PLAINTEXT ON DISK; \
485 test-only, never production"
486 );
487 });
488 Some(std::path::PathBuf::from(d))
489 }
490 _ => None,
491 }
492}
493
494fn file_backend_path(dir: &std::path::Path, r: &SecretRef) -> std::path::PathBuf {
495 let sanitize = |s: &str| s.replace(['/', '\\', '.'], "_");
497 dir.join(format!("{}.{}", sanitize(&r.service), sanitize(&r.key)))
498}
499
500fn file_backend_put(dir: &std::path::Path, r: &SecretRef, value: &str) -> Result<(), SecretError> {
501 std::fs::create_dir_all(dir)
502 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
503 std::fs::write(file_backend_path(dir, r), value)
504 .map_err(|e| SecretError::Backend(format!("file backend write: {e}")))
505}
506
507fn file_backend_publish(
508 dir: &std::path::Path,
509 r: &SecretRef,
510 value: &str,
511) -> Result<(), SecretError> {
512 use std::io::Write;
513
514 std::fs::create_dir_all(dir)
515 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
516 let destination = file_backend_path(dir, r);
517 let nonce = publication_nonce();
518 let staging = destination.with_extension(format!("stage-{nonce}"));
519 let mut options = std::fs::OpenOptions::new();
520 options.create_new(true).write(true);
521 #[cfg(unix)]
522 {
523 use std::os::unix::fs::OpenOptionsExt;
524 options.mode(0o600);
525 }
526 let mut file = options
527 .open(&staging)
528 .map_err(|e| SecretError::Backend(format!("file backend stage: {e}")))?;
529 file.write_all(value.as_bytes())
530 .and_then(|_| file.sync_all())
531 .map_err(|e| SecretError::Backend(format!("file backend stage write: {e}")))?;
532 drop(file);
533 if let Err(error) = std::fs::rename(&staging, &destination) {
534 let _ = std::fs::remove_file(&staging);
535 return Err(SecretError::Backend(format!(
536 "file backend publish rename: {error}"
537 )));
538 }
539 Ok(())
540}
541
542fn file_backend_entry_is_merely_absent(dir: &std::path::Path) -> bool {
569 match std::fs::metadata(dir) {
570 Ok(metadata) => metadata.is_dir(),
571 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
572 for ancestor in dir.ancestors().skip(1) {
577 match std::fs::metadata(ancestor) {
578 Ok(metadata) => return metadata.is_dir(),
579 Err(ancestor_error)
580 if ancestor_error.kind() == std::io::ErrorKind::NotFound => {}
581 Err(_) => return false,
582 }
583 }
584 false
585 }
586 Err(_) => false,
587 }
588}
589
590fn file_backend_get(dir: &std::path::Path, r: &SecretRef) -> Result<String, SecretError> {
591 match std::fs::read_to_string(file_backend_path(dir, r)) {
592 Ok(v) => Ok(v),
593 Err(e)
594 if e.kind() == std::io::ErrorKind::NotFound
595 && file_backend_entry_is_merely_absent(dir) =>
596 {
597 Err(SecretError::NotFound {
598 service: r.service.clone(),
599 key: r.key.clone(),
600 })
601 }
602 Err(e) => Err(SecretError::Backend(format!("file backend read: {e}"))),
603 }
604}
605
606fn file_backend_delete(dir: &std::path::Path, r: &SecretRef) -> Result<(), SecretError> {
607 match std::fs::remove_file(file_backend_path(dir, r)) {
608 Ok(()) => Ok(()),
609 Err(e)
610 if e.kind() == std::io::ErrorKind::NotFound
611 && file_backend_entry_is_merely_absent(dir) =>
612 {
613 Ok(())
614 }
615 Err(e) => Err(SecretError::Backend(format!("file backend delete: {e}"))),
616 }
617}
618
619fn file_backend_status(dir: &std::path::Path, r: &SecretRef) -> SecretStatus {
620 SecretStatus {
621 service: r.service.clone(),
622 key: r.key.clone(),
623 exists: file_backend_path(dir, r).exists(),
627 }
628}
629
630#[cfg(target_os = "macos")]
631fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
632 if let Some(dir) = file_backend_dir() {
633 return file_backend_put(&dir, r, value);
634 }
635 mac_put_via_security_cli(&r.service, &r.key, value)
636}
637
638#[cfg(target_os = "macos")]
639fn platform_publish(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
640 if let Some(dir) = file_backend_dir() {
641 return file_backend_publish(&dir, r, value);
642 }
643 mac_publish_via_security_cli(&r.service, &r.key, value)
644}
645
646#[cfg(any(not(target_os = "macos"), test))]
663const CHUNK_SENTINEL: &str = "__car_secrets_chunked_v1__:";
664#[cfg(any(target_os = "windows", test))]
665const CHUNK_SENTINEL_V2: &str = "__car_secrets_chunked_v2__:";
666#[cfg(any(target_os = "windows", test))]
667const CHUNK_SENTINEL_V3: &str = "__car_secrets_chunked_v3__:";
668#[cfg(any(target_os = "windows", test))]
669const CHUNK_VALUE_V3: &str = "__car_secrets_chunk_v3__:";
670#[cfg(any(not(target_os = "macos"), test))]
673const CHUNK_THRESHOLD_UTF16: usize = 2000;
674#[cfg(any(not(target_os = "macos"), test))]
676const CHUNK_CHARS: usize = 1000;
677#[cfg(any(target_os = "windows", test))]
681const WINDOWS_MAX_CHUNKS: usize = 1024;
682#[cfg(any(target_os = "windows", test))]
683const WINDOWS_READ_ATTEMPTS: usize = 4;
684
685#[cfg(not(target_os = "macos"))]
687fn chunk_ref(r: &SecretRef, i: usize) -> SecretRef {
688 SecretRef::new(r.service.clone(), format!("{}#chunk{}", r.key, i))
689}
690
691#[cfg(target_os = "windows")]
692fn chunk_v2_ref(r: &SecretRef, nonce: &str, i: usize) -> SecretRef {
693 SecretRef::new(r.service.clone(), format!("{}#chunkv2#{nonce}#{i}", r.key))
694}
695
696#[cfg(target_os = "windows")]
697fn chunk_v3_ref(r: &SecretRef, generation: ChunkGeneration, i: usize) -> SecretRef {
698 SecretRef::new(
699 r.service.clone(),
700 format!("{}#chunkv3#{}#{i}", r.key, generation.label()),
701 )
702}
703
704#[cfg(target_os = "windows")]
705fn chunk_v3_manifest_ref(r: &SecretRef, generation: ChunkGeneration) -> SecretRef {
706 SecretRef::new(
707 r.service.clone(),
708 format!("{}#chunkv3#{}#manifest", r.key, generation.label()),
709 )
710}
711
712#[cfg(target_os = "windows")]
713fn chunk_v3_retired_v2_ref(r: &SecretRef) -> SecretRef {
714 SecretRef::new(r.service.clone(), format!("{}#chunkv3#retired-v2", r.key))
715}
716
717#[cfg(any(not(target_os = "macos"), test))]
719fn split_on_chars(s: &str, n: usize) -> Vec<String> {
720 let mut out = Vec::new();
721 let mut cur = String::new();
722 let mut count = 0usize;
723 for ch in s.chars() {
724 cur.push(ch);
725 count += 1;
726 if count == n {
727 out.push(std::mem::take(&mut cur));
728 count = 0;
729 }
730 }
731 if !cur.is_empty() {
732 out.push(cur);
733 }
734 out
735}
736
737fn publication_nonce() -> String {
738 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
739 let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
740 let nanos = std::time::SystemTime::now()
741 .duration_since(std::time::UNIX_EPOCH)
742 .map(|duration| duration.as_nanos())
743 .unwrap_or_default();
744 format!("{:x}-{:x}-{:x}", std::process::id(), nanos, sequence)
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
748#[cfg(any(target_os = "windows", test))]
749enum ChunkGeneration {
750 A,
751 B,
752}
753
754#[cfg(any(target_os = "windows", test))]
755impl ChunkGeneration {
756 fn label(self) -> &'static str {
757 match self {
758 Self::A => "a",
759 Self::B => "b",
760 }
761 }
762
763 fn inactive(self) -> Self {
764 match self {
765 Self::A => Self::B,
766 Self::B => Self::A,
767 }
768 }
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
772#[cfg(any(target_os = "windows", test))]
773struct ChunkPublicationPlan {
774 generation: ChunkGeneration,
775 revision: String,
776 chunks: Vec<String>,
777 root: String,
778}
779
780#[cfg(any(target_os = "windows", test))]
781fn chunk_publication_plan(
782 value: &str,
783 generation: ChunkGeneration,
784 revision: &str,
785) -> Result<ChunkPublicationPlan, SecretError> {
786 if revision.is_empty() || revision.contains(':') {
787 return Err(SecretError::Backend(
788 "invalid Windows credential publication revision".to_string(),
789 ));
790 }
791 let mut chunks = split_on_chars(value, CHUNK_CHARS);
792 if chunks.is_empty() {
793 chunks.push(String::new());
794 }
795 if chunks.len() > WINDOWS_MAX_CHUNKS {
796 return Err(SecretError::Backend(format!(
797 "Windows credential publication requires {} chunks; maximum is {WINDOWS_MAX_CHUNKS}",
798 chunks.len()
799 )));
800 }
801 Ok(ChunkPublicationPlan {
802 generation,
803 revision: revision.to_string(),
804 root: format!(
805 "{CHUNK_SENTINEL_V3}{}:{revision}:{}",
806 generation.label(),
807 chunks.len()
808 ),
809 chunks,
810 })
811}
812
813#[cfg(any(target_os = "windows", test))]
814fn encode_v3_chunk(revision: &str, value: &str) -> String {
815 format!("{CHUNK_VALUE_V3}{revision}:{value}")
816}
817
818#[cfg(any(target_os = "windows", test))]
819fn decode_v3_chunk<'a>(raw: &'a str, revision: &str) -> Result<&'a str, SecretError> {
820 let payload = raw.strip_prefix(CHUNK_VALUE_V3).ok_or_else(|| {
821 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
822 })?;
823 let (stored_revision, value) = payload.split_once(':').ok_or_else(|| {
824 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
825 })?;
826 if stored_revision != revision {
827 return Err(SecretError::Backend(
828 "Windows credential chunk revision changed during read".to_string(),
829 ));
830 }
831 Ok(value)
832}
833
834#[cfg(any(target_os = "windows", test))]
835fn parse_v2_sentinel(raw: &str) -> Option<(&str, usize)> {
836 let payload = raw.strip_prefix(CHUNK_SENTINEL_V2)?;
837 let (nonce, count) = payload.rsplit_once(':')?;
838 let count = count.parse::<usize>().ok()?;
839 if nonce.is_empty() || count == 0 || count > WINDOWS_MAX_CHUNKS {
840 return None;
841 }
842 Some((nonce, count))
843}
844
845#[derive(Debug, Clone, PartialEq, Eq)]
846#[cfg(any(target_os = "windows", test))]
847enum WindowsRootLayout {
848 Inline,
849 LegacyV1 {
850 count: usize,
851 },
852 LegacyV2 {
853 nonce: String,
854 count: usize,
855 },
856 V3 {
857 generation: ChunkGeneration,
858 revision: String,
859 count: usize,
860 },
861}
862
863#[cfg(any(target_os = "windows", test))]
864fn windows_root_layout(raw: &str) -> Result<WindowsRootLayout, SecretError> {
865 if let Some(payload) = raw.strip_prefix(CHUNK_SENTINEL_V3) {
866 let (publication, count) = payload.rsplit_once(':').ok_or_else(|| {
867 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
868 })?;
869 let (generation, revision) = publication.split_once(':').ok_or_else(|| {
870 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
871 })?;
872 let generation = match generation {
873 "a" => ChunkGeneration::A,
874 "b" => ChunkGeneration::B,
875 _ => {
876 return Err(SecretError::Backend(
877 "invalid Windows v3 credential generation".to_string(),
878 ))
879 }
880 };
881 if revision.is_empty() {
882 return Err(SecretError::Backend(
883 "invalid Windows v3 credential publication revision".to_string(),
884 ));
885 }
886 let count = count
887 .parse::<usize>()
888 .ok()
889 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS);
890 return count
891 .map(|count| WindowsRootLayout::V3 {
892 generation,
893 revision: revision.to_string(),
894 count,
895 })
896 .ok_or_else(|| {
897 SecretError::Backend("invalid Windows v3 credential chunk count".to_string())
898 });
899 }
900
901 if raw.starts_with(CHUNK_SENTINEL_V2) {
902 return parse_v2_sentinel(raw)
903 .map(|(nonce, count)| WindowsRootLayout::LegacyV2 {
904 nonce: nonce.to_string(),
905 count,
906 })
907 .ok_or_else(|| {
908 SecretError::Backend("invalid Windows v2 credential root metadata".to_string())
909 });
910 }
911
912 if let Some(count) = raw.strip_prefix(CHUNK_SENTINEL) {
913 return count
914 .parse::<usize>()
915 .ok()
916 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS)
917 .map(|count| WindowsRootLayout::LegacyV1 { count })
918 .ok_or_else(|| {
919 SecretError::Backend("invalid Windows v1 credential chunk count".to_string())
920 });
921 }
922
923 Ok(WindowsRootLayout::Inline)
924}
925
926#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
927#[cfg(any(target_os = "windows", test))]
928enum WindowsCredentialSlot {
929 Root,
930 LegacyV1Chunk(usize),
931 LegacyV2Chunk {
932 nonce: String,
933 index: usize,
934 },
935 V3Chunk {
936 generation: ChunkGeneration,
937 index: usize,
938 },
939 V3Manifest(ChunkGeneration),
940 RetiredV2Manifest,
941}
942
943#[cfg(any(target_os = "windows", test))]
944trait WindowsCredentialBackend {
945 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError>;
946 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError>;
947 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError>;
948}
949
950#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
951#[cfg(any(target_os = "windows", test))]
952struct WindowsCleanupReport {
953 failures: usize,
954}
955
956#[cfg(any(target_os = "windows", test))]
957fn cleanup_windows_slot(
958 backend: &mut impl WindowsCredentialBackend,
959 slot: WindowsCredentialSlot,
960 report: &mut WindowsCleanupReport,
961) {
962 if backend.delete(&slot).is_err() {
963 report.failures += 1;
964 }
965}
966
967#[cfg(any(target_os = "windows", test))]
968fn read_generation_manifest(
969 backend: &mut impl WindowsCredentialBackend,
970 generation: ChunkGeneration,
971) -> Result<usize, SecretError> {
972 let Some(raw) = backend.read(&WindowsCredentialSlot::V3Manifest(generation))? else {
973 return Ok(0);
974 };
975 raw.parse::<usize>()
976 .ok()
977 .filter(|count| *count <= WINDOWS_MAX_CHUNKS)
978 .ok_or_else(|| {
979 SecretError::Backend("invalid Windows credential generation manifest".to_string())
980 })
981}
982
983#[cfg(any(target_os = "windows", test))]
984fn read_retired_v2_manifest(
985 backend: &mut impl WindowsCredentialBackend,
986) -> Result<Option<(String, usize)>, SecretError> {
987 let Some(raw) = backend.read(&WindowsCredentialSlot::RetiredV2Manifest)? else {
988 return Ok(None);
989 };
990 match windows_root_layout(&raw)? {
991 WindowsRootLayout::LegacyV2 { nonce, count } => Ok(Some((nonce, count))),
992 _ => Err(SecretError::Backend(
993 "invalid retired Windows v2 credential manifest".to_string(),
994 )),
995 }
996}
997
998#[cfg(any(target_os = "windows", test))]
999fn cleanup_retired_v2(
1000 backend: &mut impl WindowsCredentialBackend,
1001 nonce: &str,
1002 count: usize,
1003 report: &mut WindowsCleanupReport,
1004) {
1005 let failures_before = report.failures;
1006 for index in 0..count {
1007 cleanup_windows_slot(
1008 backend,
1009 WindowsCredentialSlot::LegacyV2Chunk {
1010 nonce: nonce.to_string(),
1011 index,
1012 },
1013 report,
1014 );
1015 }
1016 if report.failures == failures_before {
1019 cleanup_windows_slot(backend, WindowsCredentialSlot::RetiredV2Manifest, report);
1020 }
1021}
1022
1023#[cfg(any(target_os = "windows", test))]
1024fn publish_windows_value(
1025 backend: &mut impl WindowsCredentialBackend,
1026 value: &str,
1027) -> Result<WindowsCleanupReport, SecretError> {
1028 let previous_root = backend.read(&WindowsCredentialSlot::Root)?;
1029 let previous_layout = previous_root
1030 .as_deref()
1031 .map(windows_root_layout)
1032 .transpose()?;
1033 let retired_v2_before = read_retired_v2_manifest(backend)?;
1034 let newly_retired_v2 = match previous_layout.as_ref() {
1035 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1036 let root = previous_root
1037 .as_deref()
1038 .expect("a parsed legacy root came from a present credential");
1039 backend.write(&WindowsCredentialSlot::RetiredV2Manifest, root)?;
1040 Some((nonce.clone(), *count))
1041 }
1042 _ => None,
1043 };
1044 let generation = match previous_layout {
1045 Some(WindowsRootLayout::V3 { generation, .. }) => generation.inactive(),
1046 _ => ChunkGeneration::A,
1047 };
1048 let plan = chunk_publication_plan(value, generation, &publication_nonce())?;
1049
1050 let previous_bound = read_generation_manifest(backend, generation)?;
1054 let high_water = previous_bound.max(plan.chunks.len());
1055 backend.write(
1056 &WindowsCredentialSlot::V3Manifest(generation),
1057 &high_water.to_string(),
1058 )?;
1059
1060 let mut staged = 0;
1061 for (index, chunk) in plan.chunks.iter().enumerate() {
1062 let slot = WindowsCredentialSlot::V3Chunk { generation, index };
1063 if let Err(error) = backend.write(&slot, &encode_v3_chunk(&plan.revision, chunk)) {
1064 let mut ignored_cleanup = WindowsCleanupReport::default();
1065 for staged_index in 0..staged {
1066 cleanup_windows_slot(
1067 backend,
1068 WindowsCredentialSlot::V3Chunk {
1069 generation,
1070 index: staged_index,
1071 },
1072 &mut ignored_cleanup,
1073 );
1074 }
1075 return Err(error);
1076 }
1077 staged += 1;
1078 }
1079
1080 if let Err(error) = backend.write(&WindowsCredentialSlot::Root, &plan.root) {
1083 let mut ignored_cleanup = WindowsCleanupReport::default();
1084 for staged_index in 0..staged {
1085 cleanup_windows_slot(
1086 backend,
1087 WindowsCredentialSlot::V3Chunk {
1088 generation,
1089 index: staged_index,
1090 },
1091 &mut ignored_cleanup,
1092 );
1093 }
1094 return Err(error);
1095 }
1096
1097 let mut cleanup = WindowsCleanupReport::default();
1098 let tail_failures_before = cleanup.failures;
1099 for index in plan.chunks.len()..high_water {
1100 cleanup_windows_slot(
1101 backend,
1102 WindowsCredentialSlot::V3Chunk { generation, index },
1103 &mut cleanup,
1104 );
1105 }
1106 if cleanup.failures == tail_failures_before
1107 && backend
1108 .write(
1109 &WindowsCredentialSlot::V3Manifest(generation),
1110 &plan.chunks.len().to_string(),
1111 )
1112 .is_err()
1113 {
1114 cleanup.failures += 1;
1115 }
1116
1117 if let Some((nonce, count)) = retired_v2_before {
1122 if newly_retired_v2.as_ref() != Some(&(nonce.clone(), count)) {
1123 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1124 }
1125 }
1126
1127 Ok(cleanup)
1128}
1129
1130#[cfg(not(target_os = "macos"))]
1134fn clear_chunks(store: &SecretStore, r: &SecretRef) {
1135 for i in 0..1024 {
1136 let cr = chunk_ref(r, i);
1137 let Ok(entry) = store.entry(&cr) else { break };
1138 match entry.delete_credential() {
1139 Ok(_) => {}
1140 Err(keyring::Error::NoEntry) => break,
1141 Err(_) => break,
1142 }
1143 }
1144}
1145
1146#[cfg(any(target_os = "windows", test))]
1147fn read_windows_value(
1148 backend: &mut impl WindowsCredentialBackend,
1149) -> Result<Option<String>, SecretError> {
1150 for attempt in 0..WINDOWS_READ_ATTEMPTS {
1151 let Some(root) = backend.read(&WindowsCredentialSlot::Root)? else {
1152 return Ok(None);
1153 };
1154 let (slots, expected_revision) = match windows_root_layout(&root)? {
1155 WindowsRootLayout::Inline => return Ok(Some(root)),
1156 WindowsRootLayout::LegacyV1 { count } => (
1157 (0..count)
1158 .map(WindowsCredentialSlot::LegacyV1Chunk)
1159 .collect::<Vec<_>>(),
1160 None,
1161 ),
1162 WindowsRootLayout::LegacyV2 { nonce, count } => (
1163 (0..count)
1164 .map(|index| WindowsCredentialSlot::LegacyV2Chunk {
1165 nonce: nonce.clone(),
1166 index,
1167 })
1168 .collect::<Vec<_>>(),
1169 None,
1170 ),
1171 WindowsRootLayout::V3 {
1172 generation,
1173 revision,
1174 count,
1175 } => (
1176 (0..count)
1177 .map(|index| WindowsCredentialSlot::V3Chunk { generation, index })
1178 .collect::<Vec<_>>(),
1179 Some(revision),
1180 ),
1181 };
1182
1183 let mut value = String::new();
1184 let mut chunk_error = None;
1185 for slot in slots {
1186 match backend.read(&slot) {
1187 Ok(Some(chunk)) => {
1188 if let Some(revision) = expected_revision.as_deref() {
1189 match decode_v3_chunk(&chunk, revision) {
1190 Ok(chunk) => value.push_str(chunk),
1191 Err(error) => {
1192 chunk_error = Some(error);
1193 break;
1194 }
1195 }
1196 } else {
1197 value.push_str(&chunk);
1198 }
1199 }
1200 Ok(None) => {
1201 chunk_error = Some(SecretError::Backend(
1202 "Windows credential publication is incomplete".to_string(),
1203 ));
1204 break;
1205 }
1206 Err(error) => {
1207 chunk_error = Some(error);
1208 break;
1209 }
1210 }
1211 }
1212
1213 let root_after = backend.read(&WindowsCredentialSlot::Root);
1214 if matches!(&root_after, Ok(Some(current)) if current != &root) {
1215 if chunk_error.is_none() {
1216 return Ok(Some(value));
1219 }
1220 if attempt + 1 < WINDOWS_READ_ATTEMPTS {
1221 continue;
1222 }
1223 return Err(SecretError::Backend(
1224 "Windows credential root changed during every read attempt".to_string(),
1225 ));
1226 }
1227 if let Some(error) = chunk_error {
1228 return Err(error);
1229 }
1230 match root_after {
1231 Ok(Some(current)) if current == root => return Ok(Some(value)),
1232 Ok(_) if attempt + 1 < WINDOWS_READ_ATTEMPTS => continue,
1233 Ok(_) => {
1234 return Err(SecretError::Backend(
1235 "Windows credential root changed during every read attempt".to_string(),
1236 ))
1237 }
1238 Err(error) => return Err(error),
1239 }
1240 }
1241 Err(SecretError::Backend(
1242 "Windows credential read retry limit reached".to_string(),
1243 ))
1244}
1245
1246#[cfg(any(target_os = "windows", test))]
1247fn delete_windows_value(
1248 backend: &mut impl WindowsCredentialBackend,
1249) -> Result<WindowsCleanupReport, SecretError> {
1250 let root = backend.read(&WindowsCredentialSlot::Root)?;
1251 let layout = root.as_deref().map(windows_root_layout).transpose()?;
1252 let retired_v2 = read_retired_v2_manifest(backend)?;
1253
1254 let mut generation_bounds = [
1257 (
1258 ChunkGeneration::A,
1259 read_generation_manifest(backend, ChunkGeneration::A)?,
1260 ),
1261 (
1262 ChunkGeneration::B,
1263 read_generation_manifest(backend, ChunkGeneration::B)?,
1264 ),
1265 ];
1266 if let Some(WindowsRootLayout::V3 {
1267 generation, count, ..
1268 }) = layout.as_ref()
1269 {
1270 let (_, bound) = generation_bounds
1271 .iter_mut()
1272 .find(|(candidate, _)| candidate == generation)
1273 .expect("both deterministic generations are present");
1274 *bound = (*bound).max(*count);
1275 }
1276
1277 backend.delete(&WindowsCredentialSlot::Root)?;
1278
1279 let mut cleanup = WindowsCleanupReport::default();
1280 for (generation, bound) in generation_bounds {
1281 let failures_before = cleanup.failures;
1282 for index in 0..bound {
1283 cleanup_windows_slot(
1284 backend,
1285 WindowsCredentialSlot::V3Chunk { generation, index },
1286 &mut cleanup,
1287 );
1288 }
1289 if cleanup.failures == failures_before {
1290 cleanup_windows_slot(
1291 backend,
1292 WindowsCredentialSlot::V3Manifest(generation),
1293 &mut cleanup,
1294 );
1295 }
1296 }
1297 match layout {
1298 Some(WindowsRootLayout::LegacyV1 { count }) => {
1299 for index in 0..count {
1300 cleanup_windows_slot(
1301 backend,
1302 WindowsCredentialSlot::LegacyV1Chunk(index),
1303 &mut cleanup,
1304 );
1305 }
1306 }
1307 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1308 for index in 0..count {
1309 cleanup_windows_slot(
1310 backend,
1311 WindowsCredentialSlot::LegacyV2Chunk {
1312 nonce: nonce.clone(),
1313 index,
1314 },
1315 &mut cleanup,
1316 );
1317 }
1318 }
1319 _ => {}
1320 }
1321 if let Some((nonce, count)) = retired_v2 {
1322 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1323 }
1324 Ok(cleanup)
1325}
1326
1327#[cfg(not(target_os = "macos"))]
1328fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1329 if let Some(dir) = file_backend_dir() {
1330 return file_backend_put(&dir, r, value);
1331 }
1332 if cfg!(windows) {
1335 clear_chunks(store, r);
1338 if value.encode_utf16().count() > CHUNK_THRESHOLD_UTF16 {
1339 let parts = split_on_chars(value, CHUNK_CHARS);
1340 for (i, part) in parts.iter().enumerate() {
1341 let cr = chunk_ref(r, i);
1342 store
1343 .entry(&cr)?
1344 .set_password(part)
1345 .map_err(|e| classify(e, "set_password(chunk)"))?;
1346 }
1347 let sentinel = format!("{CHUNK_SENTINEL}{}", parts.len());
1350 return store
1351 .entry(r)?
1352 .set_password(&sentinel)
1353 .map_err(|e| classify(e, "set_password(sentinel)"));
1354 }
1355 }
1356 let entry = store.entry(r)?;
1357 entry
1358 .set_password(value)
1359 .map_err(|e| classify(e, "set_password"))
1360}
1361
1362#[cfg(target_os = "windows")]
1363struct KeyringWindowsBackend<'a> {
1364 store: &'a SecretStore,
1365 root: &'a SecretRef,
1366}
1367
1368#[cfg(target_os = "windows")]
1369impl KeyringWindowsBackend<'_> {
1370 fn secret_ref(&self, slot: &WindowsCredentialSlot) -> SecretRef {
1371 match slot {
1372 WindowsCredentialSlot::Root => self.root.clone(),
1373 WindowsCredentialSlot::LegacyV1Chunk(index) => chunk_ref(self.root, *index),
1374 WindowsCredentialSlot::LegacyV2Chunk { nonce, index } => {
1375 chunk_v2_ref(self.root, nonce, *index)
1376 }
1377 WindowsCredentialSlot::V3Chunk { generation, index } => {
1378 chunk_v3_ref(self.root, *generation, *index)
1379 }
1380 WindowsCredentialSlot::V3Manifest(generation) => {
1381 chunk_v3_manifest_ref(self.root, *generation)
1382 }
1383 WindowsCredentialSlot::RetiredV2Manifest => chunk_v3_retired_v2_ref(self.root),
1384 }
1385 }
1386}
1387
1388#[cfg(target_os = "windows")]
1389impl WindowsCredentialBackend for KeyringWindowsBackend<'_> {
1390 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
1391 match self.store.entry(&self.secret_ref(slot))?.get_password() {
1392 Ok(value) => Ok(Some(value)),
1393 Err(keyring::Error::NoEntry) => Ok(None),
1394 Err(error) => Err(classify(error, "get_password(windows-publish)")),
1395 }
1396 }
1397
1398 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
1399 self.store
1400 .entry(&self.secret_ref(slot))?
1401 .set_password(value)
1402 .map_err(|error| classify(error, "set_password(windows-publish)"))
1403 }
1404
1405 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
1406 match self
1407 .store
1408 .entry(&self.secret_ref(slot))?
1409 .delete_credential()
1410 {
1411 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1412 Err(error) => Err(classify(error, "delete_credential(windows-publish)")),
1413 }
1414 }
1415}
1416
1417#[cfg(target_os = "windows")]
1418fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1419 if let Some(dir) = file_backend_dir() {
1420 return file_backend_publish(&dir, r, value);
1421 }
1422 let mut backend = KeyringWindowsBackend { store, root: r };
1423 let cleanup = publish_windows_value(&mut backend, value)?;
1424 if cleanup.failures > 0 {
1425 tracing::warn!(
1426 cleanup_failures = cleanup.failures,
1427 "Windows credential publication committed; bounded cleanup deferred"
1428 );
1429 }
1430 Ok(())
1431}
1432
1433#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1434fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1435 if let Some(dir) = file_backend_dir() {
1436 return file_backend_publish(&dir, r, value);
1437 }
1438 store
1439 .entry(r)?
1440 .set_password(value)
1441 .map_err(|error| classify(error, "publish_password"))
1442}
1443
1444#[cfg(target_os = "macos")]
1445fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1446 if let Some(dir) = file_backend_dir() {
1447 return file_backend_get(&dir, r);
1448 }
1449 mac_get_via_security_cli(r)
1450}
1451
1452#[cfg(target_os = "windows")]
1453fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1454 if let Some(dir) = file_backend_dir() {
1455 return file_backend_get(&dir, r);
1456 }
1457 let mut backend = KeyringWindowsBackend { store, root: r };
1458 match read_windows_value(&mut backend)? {
1459 Some(value) => Ok(value),
1460 None => Err(SecretError::NotFound {
1461 service: r.service.clone(),
1462 key: r.key.clone(),
1463 }),
1464 }
1465}
1466
1467#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1468fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1469 if let Some(dir) = file_backend_dir() {
1470 return file_backend_get(&dir, r);
1471 }
1472 match store.entry(r)?.get_password() {
1473 Ok(value) => Ok(value),
1474 Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
1475 service: r.service.clone(),
1476 key: r.key.clone(),
1477 }),
1478 Err(error) => Err(classify(error, "get_password")),
1479 }
1480}
1481
1482#[cfg(target_os = "macos")]
1483fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1484 if let Some(dir) = file_backend_dir() {
1485 return file_backend_delete(&dir, r);
1486 }
1487 mac_delete_via_security_cli(r)
1488}
1489
1490#[cfg(target_os = "windows")]
1491fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1492 if let Some(dir) = file_backend_dir() {
1493 return file_backend_delete(&dir, r);
1494 }
1495 let mut backend = KeyringWindowsBackend { store, root: r };
1496 let cleanup = delete_windows_value(&mut backend)?;
1497 if cleanup.failures > 0 {
1498 tracing::warn!(
1499 cleanup_failures = cleanup.failures,
1500 "Windows credential root deleted; bounded cleanup deferred"
1501 );
1502 }
1503 Ok(())
1504}
1505
1506#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1507fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1508 if let Some(dir) = file_backend_dir() {
1509 return file_backend_delete(&dir, r);
1510 }
1511 match store.entry(r)?.delete_credential() {
1512 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
1513 Err(error) => Err(classify(error, "delete_credential")),
1514 }
1515}
1516
1517#[cfg(target_os = "macos")]
1518fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1519 if let Some(dir) = file_backend_dir() {
1520 return Ok(file_backend_status(&dir, r));
1521 }
1522 mac_status_via_security_cli(r)
1523}
1524
1525#[cfg(not(target_os = "macos"))]
1526fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1527 if let Some(dir) = file_backend_dir() {
1528 return Ok(file_backend_status(&dir, r));
1529 }
1530 let entry = store.entry(r)?;
1531 let exists = match entry.get_password() {
1532 Ok(_) => true,
1533 Err(keyring::Error::NoEntry) => false,
1534 Err(other) => return Err(classify(other, "status")),
1535 };
1536 Ok(SecretStatus {
1537 service: r.service.clone(),
1538 key: r.key.clone(),
1539 exists,
1540 })
1541}
1542
1543#[cfg(target_os = "macos")]
1572fn platform_availability(_store: &SecretStore) -> AvailabilityCheck {
1573 mac_availability_via_security_cli_with(&SystemSecurityCli)
1574}
1575
1576#[cfg(target_os = "macos")]
1577fn mac_availability_via_security_cli_with(cli: &impl SecurityCli) -> AvailabilityCheck {
1578 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1579 match mac_exists_via_security_cli_with(&probe, cli) {
1580 Ok(_) => AvailabilityCheck {
1583 available: true,
1584 reason: None,
1585 },
1586 Err(error) => AvailabilityCheck {
1587 available: false,
1588 reason: Some(error.to_string()),
1589 },
1590 }
1591}
1592
1593#[cfg(not(target_os = "macos"))]
1594fn platform_availability(store: &SecretStore) -> AvailabilityCheck {
1595 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1596 match store.entry(&probe) {
1597 Ok(entry) => match entry.get_password() {
1598 Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
1599 available: true,
1600 reason: None,
1601 },
1602 Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
1603 available: false,
1604 reason: Some(format!("platform failure: {e}")),
1605 },
1606 Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
1607 available: false,
1608 reason: Some(format!("no storage access: {e}")),
1609 },
1610 Err(_) => AvailabilityCheck {
1617 available: true,
1618 reason: None,
1619 },
1620 },
1621 Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
1622 available: false,
1623 reason: Some(reason),
1624 },
1625 Err(other) => AvailabilityCheck {
1626 available: false,
1627 reason: Some(other.to_string()),
1628 },
1629 }
1630}
1631
1632#[cfg(target_os = "macos")]
1649fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
1650 mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
1651}
1652
1653#[cfg(target_os = "macos")]
1654fn mac_publish_via_security_cli(
1655 service: &str,
1656 account: &str,
1657 value: &str,
1658) -> Result<(), SecretError> {
1659 mac_publish_via_security_cli_with(service, account, value, &SystemSecurityCli)
1660}
1661
1662#[cfg(target_os = "macos")]
1663fn mac_publish_via_security_cli_with(
1664 service: &str,
1665 account: &str,
1666 value: &str,
1667 cli: &impl SecurityCli,
1668) -> Result<(), SecretError> {
1669 let output = cli
1670 .output(&[
1671 "add-generic-password",
1672 "-U",
1673 "-A",
1674 "-s",
1675 service,
1676 "-a",
1677 account,
1678 "-w",
1679 value,
1680 ])
1681 .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
1682 if output.success {
1683 Ok(())
1684 } else {
1685 Err(security_cli_backend_error("add-generic-password", output))
1686 }
1687}
1688
1689#[cfg(target_os = "macos")]
1690fn mac_put_via_security_cli_with(
1691 service: &str,
1692 account: &str,
1693 value: &str,
1694 cli: &impl SecurityCli,
1695) -> Result<(), SecretError> {
1696 let _ = cli.output(&["delete-generic-password", "-s", service, "-a", account]);
1700
1701 let output = cli
1702 .output(&[
1703 "add-generic-password",
1704 "-U", "-A", "-s",
1707 service,
1708 "-a",
1709 account,
1710 "-w",
1711 value,
1712 ])
1713 .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
1714 if output.success {
1715 return Ok(());
1716 }
1717 Err(security_cli_backend_error("add-generic-password", output))
1718}
1719
1720#[cfg(target_os = "macos")]
1721const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
1722
1723#[cfg(target_os = "macos")]
1724#[derive(Debug)]
1725struct SecurityCliOutput {
1726 success: bool,
1727 code: Option<i32>,
1728 stdout: Vec<u8>,
1729 stderr: Vec<u8>,
1730 prompted: bool,
1742 timed_out: bool,
1744}
1745
1746#[cfg(target_os = "macos")]
1747trait SecurityCli {
1748 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
1749}
1750
1751#[cfg(target_os = "macos")]
1752struct SystemSecurityCli;
1753
1754#[cfg(target_os = "macos")]
1755impl SecurityCli for SystemSecurityCli {
1756 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
1757 let mut command = std::process::Command::new("/usr/bin/security");
1758 command.args(args);
1759 if let Some(keychain_path) = selected_keychain_path()? {
1760 command.arg(keychain_path);
1761 }
1762 let run = bounded_command_output(&mut command, SECURITY_CLI_TIMEOUT, &describe_item(args))?;
1763 Ok(SecurityCliOutput {
1764 success: run.output.status.success(),
1765 code: run.output.status.code(),
1766 stdout: run.output.stdout,
1767 stderr: run.output.stderr,
1768 prompted: run.prompted,
1769 timed_out: run.timed_out,
1770 })
1771 }
1772}
1773
1774#[cfg(target_os = "macos")]
1775const KEYCHAIN_PATH_ENV: &str = "CAR_KEYCHAIN_PATH";
1776
1777#[cfg(target_os = "macos")]
1778const KEYCHAIN_PROOF_ROOT_ENV: &str = "CAR_KEYCHAIN_PROOF_ROOT";
1779
1780#[cfg(target_os = "macos")]
1783fn selected_keychain_path() -> std::io::Result<Option<std::path::PathBuf>> {
1784 let Some(path) = std::env::var_os(KEYCHAIN_PATH_ENV).filter(|value| !value.is_empty()) else {
1785 return Ok(None);
1786 };
1787 let proof_root = std::env::var_os(KEYCHAIN_PROOF_ROOT_ENV)
1788 .filter(|value| !value.is_empty())
1789 .ok_or_else(|| {
1790 std::io::Error::new(
1791 std::io::ErrorKind::InvalidInput,
1792 format!("{KEYCHAIN_PATH_ENV} requires {KEYCHAIN_PROOF_ROOT_ENV}"),
1793 )
1794 })?;
1795 validate_keychain_path(
1796 std::path::Path::new(&path),
1797 std::path::Path::new(&proof_root),
1798 )
1799 .map(Some)
1800}
1801
1802#[cfg(target_os = "macos")]
1803fn validate_keychain_path(
1804 path: &std::path::Path,
1805 proof_root: &std::path::Path,
1806) -> std::io::Result<std::path::PathBuf> {
1807 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1808
1809 if !path.is_absolute() || !proof_root.is_absolute() {
1810 return Err(std::io::Error::new(
1811 std::io::ErrorKind::InvalidInput,
1812 "isolated Keychain path and proof root must be absolute",
1813 ));
1814 }
1815
1816 let expected_uid = current_effective_uid();
1817 let root_metadata = std::fs::symlink_metadata(proof_root)?;
1818 if root_metadata.file_type().is_symlink()
1819 || !root_metadata.is_dir()
1820 || root_metadata.uid() != expected_uid
1821 || root_metadata.permissions().mode() & 0o077 != 0
1822 {
1823 return Err(std::io::Error::new(
1824 std::io::ErrorKind::PermissionDenied,
1825 "Keychain proof root must be an owner-private, non-symlink directory owned by the current user",
1826 ));
1827 }
1828
1829 let path_metadata = std::fs::symlink_metadata(path)?;
1830 if path_metadata.file_type().is_symlink()
1831 || !path_metadata.is_file()
1832 || path_metadata.uid() != expected_uid
1833 || path_metadata.permissions().mode() & 0o077 != 0
1834 {
1835 return Err(std::io::Error::new(
1836 std::io::ErrorKind::PermissionDenied,
1837 "isolated Keychain must be an owner-private, non-symlink regular file owned by the current user",
1838 ));
1839 }
1840
1841 let canonical_root = std::fs::canonicalize(proof_root)?;
1842 let canonical_path = std::fs::canonicalize(path)?;
1843 if !canonical_path.starts_with(&canonical_root) || canonical_path == canonical_root {
1844 return Err(std::io::Error::new(
1845 std::io::ErrorKind::PermissionDenied,
1846 "isolated Keychain must be canonically contained by its proof root",
1847 ));
1848 }
1849 Ok(canonical_path)
1850}
1851
1852#[cfg(target_os = "macos")]
1853fn current_effective_uid() -> u32 {
1854 unsafe extern "C" {
1855 fn geteuid() -> u32;
1856 }
1857 unsafe { geteuid() }
1859}
1860
1861#[cfg(target_os = "macos")]
1862const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
1863
1864#[cfg(target_os = "macos")]
1869const SECURITY_CLI_INTERACTIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
1870
1871#[cfg(target_os = "macos")]
1882fn security_agent_is_prompting() -> bool {
1883 std::process::Command::new("/usr/bin/pgrep")
1884 .arg("-x")
1885 .arg("SecurityAgent")
1886 .stdout(std::process::Stdio::null())
1887 .stderr(std::process::Stdio::null())
1888 .status()
1889 .map(|s| s.success())
1890 .unwrap_or(false)
1891}
1892
1893#[cfg(target_os = "macos")]
1933const PROMPT_EVIDENCE_MIN: std::time::Duration = std::time::Duration::from_millis(500);
1934
1935#[cfg(target_os = "macos")]
1941fn dialog_is_evidence_for_this_read(dialog_on_screen: bool, elapsed: std::time::Duration) -> bool {
1942 dialog_on_screen && elapsed >= PROMPT_EVIDENCE_MIN
1943}
1944
1945#[cfg(target_os = "macos")]
1947#[derive(Debug)]
1948struct BoundedRun {
1949 output: std::process::Output,
1950 prompted: bool,
1951 timed_out: bool,
1952}
1953
1954#[cfg(target_os = "macos")]
1969fn describe_item(args: &[&str]) -> String {
1970 let flag = |name: &str| {
1971 args.iter()
1972 .position(|a| *a == name)
1973 .and_then(|i| args.get(i + 1))
1974 .copied()
1975 };
1976 match (flag("-s"), flag("-a")) {
1977 (Some(service), Some(account)) => format!("{service}/{account}"),
1978 (Some(service), None) => service.to_string(),
1979 (None, Some(account)) => account.to_string(),
1980 (None, None) => args.first().copied().unwrap_or("security").to_string(),
1983 }
1984}
1985
1986#[cfg(target_os = "macos")]
1993fn keychain_prompt_notice(item: &str) -> String {
1994 format!(
1995 "waiting on a macOS keychain prompt for \"{item}\" (up to {}s) — CAR is not \
1996 hung. Click \"Always Allow\" on the dialog (it may be behind another \
1997 window), or grant the \"car\" service access in Keychain Access.",
1998 SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs()
1999 )
2000}
2001
2002#[cfg(target_os = "macos")]
2003fn bounded_command_output(
2004 command: &mut std::process::Command,
2005 timeout: std::time::Duration,
2006 item: &str,
2007) -> std::io::Result<BoundedRun> {
2008 bounded_command_output_with(command, timeout, security_agent_is_prompting, || {
2009 tracing::warn!("{}", keychain_prompt_notice(item));
2016 })
2017}
2018
2019#[cfg(target_os = "macos")]
2031fn bounded_command_output_with(
2032 command: &mut std::process::Command,
2033 timeout: std::time::Duration,
2034 dialog_probe: impl Fn() -> bool,
2035 on_waiting_for_user: impl Fn(),
2036) -> std::io::Result<BoundedRun> {
2037 use std::io::Read;
2038 use std::process::Stdio;
2039 use std::time::Instant;
2040
2041 command.stdout(Stdio::piped()).stderr(Stdio::piped());
2042 let mut child = command.spawn()?;
2043 let stdout = child
2044 .stdout
2045 .take()
2046 .ok_or_else(|| std::io::Error::other("keychain helper stdout was not piped"))?;
2047 let stderr = child
2048 .stderr
2049 .take()
2050 .ok_or_else(|| std::io::Error::other("keychain helper stderr was not piped"))?;
2051 let stdout_reader = std::thread::spawn(move || {
2052 let mut bytes = Vec::new();
2053 let mut stdout = stdout;
2054 stdout.read_to_end(&mut bytes)?;
2055 Ok::<_, std::io::Error>(bytes)
2056 });
2057 let stderr_reader = std::thread::spawn(move || {
2058 let mut bytes = Vec::new();
2059 let mut stderr = stderr;
2060 stderr.read_to_end(&mut bytes)?;
2061 Ok::<_, std::io::Error>(bytes)
2062 });
2063 let started = Instant::now();
2064 let mut prompted = false;
2067 let (status, timed_out) = loop {
2068 if let Some(status) = child.try_wait()? {
2069 break (status, false);
2070 }
2071 let dialog_on_screen = dialog_probe();
2084 if !prompted && dialog_is_evidence_for_this_read(dialog_on_screen, started.elapsed()) {
2100 prompted = true;
2101 on_waiting_for_user();
2119 }
2120 let deadline = if dialog_on_screen {
2121 SECURITY_CLI_INTERACTIVE_TIMEOUT
2122 } else {
2123 timeout
2124 };
2125 if started.elapsed() >= deadline {
2126 let _ = child.kill();
2127 break (child.wait()?, true);
2128 }
2129 std::thread::sleep(std::time::Duration::from_millis(10));
2130 };
2131 let join_reader = |reader: std::thread::JoinHandle<std::io::Result<Vec<u8>>>,
2132 stream: &str|
2133 -> std::io::Result<Vec<u8>> {
2134 reader.join().map_err(|_| {
2135 std::io::Error::other(format!("keychain helper {stream} reader panicked"))
2136 })?
2137 };
2138 let stdout = join_reader(stdout_reader, "stdout")?;
2139 let mut stderr = join_reader(stderr_reader, "stderr")?;
2140 if timed_out {
2141 stderr.extend_from_slice(
2148 format!(
2149 "\nCAR killed the keychain helper after {}ms. This usually means a macOS \
2150 keychain prompt is open and waiting: click \"Always Allow\" (or grant access \
2151 to the \"car\" service in Keychain Access). Until it is answered, CAR cannot \
2152 read your saved credentials and will report that no account is signed in.",
2153 timeout.as_millis()
2154 )
2155 .as_bytes(),
2156 );
2157 }
2158 Ok(BoundedRun {
2159 output: std::process::Output {
2160 status,
2161 stdout,
2162 stderr,
2163 },
2164 prompted,
2165 timed_out,
2166 })
2167}
2168
2169#[cfg(target_os = "macos")]
2177fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
2178 mac_get_via_security_cli_with(r, &SystemSecurityCli)
2179}
2180
2181#[cfg(target_os = "macos")]
2188const ACL_REPAIR_OPT_OUT_ENV: &str = "CAR_KEYCHAIN_NO_ACL_REPAIR";
2189
2190#[cfg(target_os = "macos")]
2196fn acl_repair_attempted() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>>
2197{
2198 static ATTEMPTED: std::sync::OnceLock<
2199 std::sync::Mutex<std::collections::HashSet<(String, String)>>,
2200 > = std::sync::OnceLock::new();
2201 ATTEMPTED.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
2202}
2203
2204#[cfg(target_os = "macos")]
2233fn mac_repair_item_acl_with(r: &SecretRef, value: &str, cli: &impl SecurityCli) {
2234 if value.is_empty() {
2235 return;
2236 }
2237 if std::env::var(ACL_REPAIR_OPT_OUT_ENV).is_ok_and(|v| v == "1") {
2238 return;
2239 }
2240 {
2241 let mut attempted = match acl_repair_attempted().lock() {
2242 Ok(guard) => guard,
2243 Err(poisoned) => poisoned.into_inner(),
2244 };
2245 if !attempted.insert((r.service.clone(), r.key.clone())) {
2246 return;
2247 }
2248 }
2249
2250 if mac_put_via_security_cli_with(&r.service, &r.key, value, cli).is_err() {
2251 let _ = mac_put_via_security_cli_with(&r.service, &r.key, value, cli);
2254 return;
2255 }
2256
2257 let restored = matches!(
2260 mac_get_via_security_cli_raw(r, cli),
2261 Ok(ref got) if got == value
2262 );
2263 if !restored {
2264 let _ = mac_put_via_security_cli_with(&r.service, &r.key, value, cli);
2265 }
2266}
2267
2268#[cfg(target_os = "macos")]
2271fn mac_get_via_security_cli_raw(
2272 r: &SecretRef,
2273 cli: &impl SecurityCli,
2274) -> Result<String, SecretError> {
2275 let output = cli
2276 .output(&[
2277 "find-generic-password",
2278 "-s",
2279 &r.service,
2280 "-a",
2281 &r.key,
2282 "-g",
2283 ])
2284 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2285 if !output.success {
2286 return security_cli_not_found_or_backend("find-generic-password", r, output);
2287 }
2288 mac_parse_security_cli_password(&output)
2289}
2290
2291#[cfg(target_os = "macos")]
2292fn mac_get_via_security_cli_with(
2293 r: &SecretRef,
2294 cli: &impl SecurityCli,
2295) -> Result<String, SecretError> {
2296 let output = cli
2297 .output(&[
2298 "find-generic-password",
2299 "-s",
2300 &r.service,
2301 "-a",
2302 &r.key,
2303 "-g",
2304 ])
2305 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2306 if !output.success {
2307 return security_cli_not_found_or_backend("find-generic-password", r, output);
2308 }
2309 let prompted = output.prompted;
2310 let value = mac_parse_security_cli_password(&output)?;
2311 if prompted {
2314 mac_repair_item_acl_with(r, &value, cli);
2315 }
2316 Ok(value)
2317}
2318
2319#[cfg(target_os = "macos")]
2320fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
2321 let line = mac_security_cli_text(&output.stderr, "stderr")?
2322 .lines()
2323 .find(|line| line.starts_with("password:"))
2324 .or_else(|| {
2325 mac_security_cli_text(&output.stdout, "stdout")
2326 .ok()
2327 .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
2328 })
2329 .ok_or_else(|| {
2330 SecretError::Backend(
2331 "/usr/bin/security find-generic-password -g did not print a password line"
2332 .to_string(),
2333 )
2334 })?;
2335
2336 let payload = line
2337 .strip_prefix("password:")
2338 .expect("password line prefix was checked")
2339 .trim_start();
2340
2341 if payload.is_empty() {
2342 return Ok(String::new());
2343 }
2344
2345 let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
2346 mac_decode_security_cli_hex_password(hex_and_preview)?
2347 } else {
2348 mac_decode_security_cli_quoted_password(payload)?
2349 };
2350
2351 String::from_utf8(bytes).map_err(|e| {
2352 SecretError::Backend(format!(
2353 "/usr/bin/security find-generic-password password was not valid utf-8: {}",
2354 e
2355 ))
2356 })
2357}
2358
2359#[cfg(target_os = "macos")]
2360fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
2361 std::str::from_utf8(bytes).map_err(|e| {
2362 SecretError::Backend(format!(
2363 "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
2364 ))
2365 })
2366}
2367
2368#[cfg(target_os = "macos")]
2369fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
2370 let hex: String = hex_and_preview
2371 .chars()
2372 .take_while(|c| c.is_ascii_hexdigit())
2373 .collect();
2374 if hex.is_empty() || !hex.len().is_multiple_of(2) {
2375 return Err(SecretError::Backend(format!(
2376 "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
2377 )));
2378 }
2379
2380 (0..hex.len())
2381 .step_by(2)
2382 .map(|i| {
2383 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
2384 SecretError::Backend(format!(
2385 "/usr/bin/security find-generic-password printed invalid password hex: {e}"
2386 ))
2387 })
2388 })
2389 .collect()
2390}
2391
2392#[cfg(target_os = "macos")]
2393fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
2394 let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
2395 match quoted {
2396 Some(value) => Ok(value.as_bytes().to_vec()),
2397 None => Err(SecretError::Backend(
2398 "/usr/bin/security find-generic-password printed an unrecognized password line"
2399 .to_string(),
2400 )),
2401 }
2402}
2403
2404#[cfg(target_os = "macos")]
2405fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
2406 mac_status_via_security_cli_with(r, &SystemSecurityCli)
2407}
2408
2409#[cfg(target_os = "macos")]
2410fn mac_status_via_security_cli_with(
2411 r: &SecretRef,
2412 cli: &impl SecurityCli,
2413) -> Result<SecretStatus, SecretError> {
2414 let exists = mac_exists_via_security_cli_with(r, cli)?;
2415 Ok(SecretStatus {
2416 service: r.service.clone(),
2417 key: r.key.clone(),
2418 exists,
2419 })
2420}
2421
2422#[cfg(target_os = "macos")]
2427fn mac_exists_via_security_cli_with(
2428 r: &SecretRef,
2429 cli: &impl SecurityCli,
2430) -> Result<bool, SecretError> {
2431 let output = cli
2432 .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
2433 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2434 if output.success {
2435 return Ok(true);
2436 }
2437 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2438 return Ok(false);
2439 }
2440 Err(security_cli_backend_error("find-generic-password", output))
2441}
2442
2443#[cfg(target_os = "macos")]
2444fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
2445 mac_delete_via_security_cli_with(r, &SystemSecurityCli)
2446}
2447
2448#[cfg(target_os = "macos")]
2451fn mac_delete_via_security_cli_with(
2452 r: &SecretRef,
2453 cli: &impl SecurityCli,
2454) -> Result<(), SecretError> {
2455 let output = cli
2456 .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
2457 .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
2458 if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2459 return Ok(());
2460 }
2461 Err(security_cli_backend_error(
2462 "delete-generic-password",
2463 output,
2464 ))
2465}
2466
2467#[cfg(target_os = "macos")]
2468fn security_cli_not_found_or_backend<T>(
2469 command: &str,
2470 r: &SecretRef,
2471 output: SecurityCliOutput,
2472) -> Result<T, SecretError> {
2473 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2474 return Err(SecretError::NotFound {
2475 service: r.service.clone(),
2476 key: r.key.clone(),
2477 });
2478 }
2479 Err(security_cli_backend_error(command, output))
2480}
2481
2482#[cfg(target_os = "macos")]
2483fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
2484 SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
2485}
2486
2487#[cfg(target_os = "macos")]
2488fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
2489 let stderr = String::from_utf8_lossy(&output.stderr);
2490 if output.timed_out {
2491 return classify_helper_timeout(command);
2492 }
2493 let code = output.code.unwrap_or(-1);
2494 match classify_security_error(code, stderr.trim()) {
2495 SecretError::Backend(_) => SecretError::Backend(format!(
2496 "/usr/bin/security {command} failed: code={code} {}",
2497 stderr.trim()
2498 )),
2499 typed => typed,
2500 }
2501}
2502
2503#[cfg(target_os = "macos")]
2504fn classify_security_error(code: i32, detail: &str) -> SecretError {
2505 let normalized = detail.to_ascii_lowercase();
2506 if code == -128 || (code == 128 && normalized.contains("cancel")) {
2507 return SecretError::UserCancelled {
2508 message: detail.to_string(),
2509 };
2510 }
2511 if code == -25293
2512 || code == 51
2513 || normalized.contains("authorization denied")
2514 || normalized.contains("auth denied")
2515 || normalized.contains("interaction is not allowed")
2516 {
2517 return SecretError::AccessDenied {
2518 message: detail.to_string(),
2519 };
2520 }
2521 SecretError::Backend(format!("macOS security error: code={code} {detail}"))
2522}
2523
2524#[cfg(target_os = "macos")]
2525fn classify_helper_timeout(operation: &str) -> SecretError {
2526 SecretError::HelperTimedOut {
2527 operation: operation.to_string(),
2528 }
2529}
2530
2531#[cfg(not(target_os = "macos"))]
2537fn classify(e: keyring::Error, op: &str) -> SecretError {
2538 use keyring::Error as K;
2539 match e {
2540 K::NoEntry => SecretError::NotFound {
2541 service: String::new(),
2542 key: String::new(),
2543 },
2544 K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2545 K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2546 K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
2547 other => SecretError::Backend(format!("{}: {}", op, other)),
2548 }
2549}
2550
2551#[cfg(test)]
2552mod chunk_tests {
2553 use super::*;
2554 use std::collections::BTreeMap;
2555
2556 #[test]
2557 fn split_on_chars_covers_boundaries() {
2558 assert_eq!(split_on_chars("", 3), Vec::<String>::new());
2559 assert_eq!(split_on_chars("abc", 3), vec!["abc"]);
2560 assert_eq!(split_on_chars("abcd", 3), vec!["abc", "d"]);
2561 assert_eq!(split_on_chars("abcdef", 2), vec!["ab", "cd", "ef"]);
2562 let big: String = "x".repeat(4000);
2564 let joined: String = split_on_chars(&big, CHUNK_CHARS).concat();
2565 assert_eq!(joined, big);
2566 }
2567
2568 #[test]
2569 fn sentinel_round_trips_the_chunk_count() {
2570 let n = split_on_chars(&"y".repeat(3300), CHUNK_CHARS).len();
2571 let sentinel = format!("{CHUNK_SENTINEL}{n}");
2572 let parsed = sentinel
2573 .strip_prefix(CHUNK_SENTINEL)
2574 .and_then(|s| s.parse::<usize>().ok());
2575 assert_eq!(parsed, Some(4)); assert!("eyJhbGciOi.reallongjwt"
2578 .strip_prefix(CHUNK_SENTINEL)
2579 .is_none());
2580 }
2581
2582 #[test]
2583 fn threshold_leaves_small_values_inline() {
2584 assert!("short-api-key".encode_utf16().count() <= CHUNK_THRESHOLD_UTF16);
2587 assert!("z".repeat(2001).encode_utf16().count() > CHUNK_THRESHOLD_UTF16);
2588 }
2589
2590 #[derive(Debug, Clone)]
2591 struct FailureRule {
2592 slot: WindowsCredentialSlot,
2593 matches_to_skip: usize,
2594 }
2595
2596 #[derive(Debug, Clone, Default)]
2597 struct MemoryWindowsBackend {
2598 entries: BTreeMap<WindowsCredentialSlot, String>,
2599 mutation_calls: usize,
2600 crash_after_mutation: Option<usize>,
2601 fail_write: Option<FailureRule>,
2602 fail_delete: Option<FailureRule>,
2603 }
2604
2605 impl MemoryWindowsBackend {
2606 fn after_mutation(&mut self) {
2607 self.mutation_calls += 1;
2608 if self.crash_after_mutation == Some(self.mutation_calls) {
2609 panic!("injected Windows credential process crash");
2610 }
2611 }
2612
2613 fn should_fail(rule: &mut Option<FailureRule>, slot: &WindowsCredentialSlot) -> bool {
2614 let Some(candidate) = rule.as_mut() else {
2615 return false;
2616 };
2617 if &candidate.slot != slot {
2618 return false;
2619 }
2620 if candidate.matches_to_skip > 0 {
2621 candidate.matches_to_skip -= 1;
2622 return false;
2623 }
2624 *rule = None;
2625 true
2626 }
2627
2628 fn reset_faults(&mut self) {
2629 self.mutation_calls = 0;
2630 self.crash_after_mutation = None;
2631 self.fail_write = None;
2632 self.fail_delete = None;
2633 }
2634
2635 fn root(&self) -> String {
2636 self.entries
2637 .get(&WindowsCredentialSlot::Root)
2638 .expect("root credential")
2639 .clone()
2640 }
2641 }
2642
2643 impl WindowsCredentialBackend for MemoryWindowsBackend {
2644 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2645 Ok(self.entries.get(slot).cloned())
2646 }
2647
2648 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2649 if Self::should_fail(&mut self.fail_write, slot) {
2650 return Err(SecretError::Backend(
2651 "injected Windows credential write failure".to_string(),
2652 ));
2653 }
2654 self.entries.insert(slot.clone(), value.to_string());
2655 self.after_mutation();
2656 Ok(())
2657 }
2658
2659 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2660 if Self::should_fail(&mut self.fail_delete, slot) {
2661 return Err(SecretError::Backend(
2662 "injected Windows credential cleanup failure".to_string(),
2663 ));
2664 }
2665 self.entries.remove(slot);
2666 self.after_mutation();
2667 Ok(())
2668 }
2669 }
2670
2671 fn publish(backend: &mut MemoryWindowsBackend, value: &str) -> WindowsCleanupReport {
2672 publish_windows_value(backend, value).expect("publication")
2673 }
2674
2675 fn read(backend: &mut impl WindowsCredentialBackend) -> String {
2676 read_windows_value(backend)
2677 .expect("read succeeds")
2678 .expect("root exists")
2679 }
2680
2681 fn legacy_v2(value: &str, nonce: &str) -> MemoryWindowsBackend {
2682 let mut backend = MemoryWindowsBackend::default();
2683 let chunks = split_on_chars(value, CHUNK_CHARS);
2684 backend.entries.insert(
2685 WindowsCredentialSlot::Root,
2686 format!("{CHUNK_SENTINEL_V2}{nonce}:{}", chunks.len()),
2687 );
2688 for (index, chunk) in chunks.into_iter().enumerate() {
2689 backend.entries.insert(
2690 WindowsCredentialSlot::LegacyV2Chunk {
2691 nonce: nonce.to_string(),
2692 index,
2693 },
2694 chunk,
2695 );
2696 }
2697 backend
2698 }
2699
2700 fn assert_backend_error(error: SecretError, needle: &str) {
2701 match error {
2702 SecretError::Backend(message) => assert!(message.contains(needle), "{message}"),
2703 other => panic!("expected backend error, got {other:?}"),
2704 }
2705 }
2706
2707 #[test]
2708 fn v3_publication_uses_revisioned_dual_generation_roots() {
2709 let value = "v".repeat(3300);
2710 let plan = chunk_publication_plan(&value, ChunkGeneration::B, "revision-7").unwrap();
2711
2712 assert_eq!(plan.generation, ChunkGeneration::B);
2713 assert_eq!(plan.chunks.concat(), value);
2714 assert_eq!(
2715 windows_root_layout(&plan.root).unwrap(),
2716 WindowsRootLayout::V3 {
2717 generation: ChunkGeneration::B,
2718 revision: "revision-7".to_string(),
2719 count: 4,
2720 }
2721 );
2722 assert!(
2723 plan.chunks
2724 .iter()
2725 .all(|chunk| chunk.encode_utf16().count() <= CHUNK_CHARS),
2726 "every staged credential must remain below the platform cap"
2727 );
2728 }
2729
2730 #[test]
2731 fn reader_capturing_old_root_finishes_after_writer_swaps_root() {
2732 let old = "old-".repeat(900);
2733 let new = "new-".repeat(900);
2734 let mut backend = MemoryWindowsBackend::default();
2735 publish(&mut backend, &old);
2736
2737 let mut reader = InterleavingReader::new(backend, vec![new.as_str()]);
2738 assert_eq!(read(&mut reader), old);
2739 assert_eq!(read(&mut reader.inner), new);
2740 }
2741
2742 #[test]
2743 fn reader_detects_generation_aba_and_retries_latest_root() {
2744 let old = "old-".repeat(900);
2745 let middle = "mid-".repeat(1100);
2746 let latest = "latest-".repeat(700);
2747 let mut backend = MemoryWindowsBackend::default();
2748 publish(&mut backend, &old);
2749
2750 let mut reader = InterleavingReader::new(backend, vec![middle.as_str(), latest.as_str()]);
2751 assert_eq!(read(&mut reader), latest);
2752 assert!(reader.root_reads >= 4, "the ABA path must consume a retry");
2753 }
2754
2755 #[test]
2756 fn legacy_nonce_chunks_survive_the_first_v3_root_swap_then_recover() {
2757 let old = "legacy-".repeat(700);
2758 let replacement = "replacement-".repeat(500);
2759 let followup = "followup-".repeat(500);
2760 let backend = legacy_v2(&old, "legacy-nonce");
2761
2762 let mut reader = InterleavingReader::new(backend, vec![replacement.as_str()]);
2763 assert_eq!(read(&mut reader), old);
2764 assert!(reader
2765 .inner
2766 .entries
2767 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2768 assert!(reader
2769 .inner
2770 .entries
2771 .contains_key(&WindowsCredentialSlot::LegacyV2Chunk {
2772 nonce: "legacy-nonce".to_string(),
2773 index: 0,
2774 }));
2775
2776 publish(&mut reader.inner, &followup);
2777 assert!(!reader
2778 .inner
2779 .entries
2780 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2781 assert!(!reader.inner.entries.keys().any(|slot| matches!(
2782 slot,
2783 WindowsCredentialSlot::LegacyV2Chunk { nonce, .. } if nonce == "legacy-nonce"
2784 )));
2785 }
2786
2787 #[test]
2788 fn crash_after_every_publish_mutation_preserves_a_readable_generation() {
2789 let old = "old-".repeat(1200);
2790 let current = "current-".repeat(900);
2791 let replacement = "replacement-".repeat(300);
2792 let mut base = MemoryWindowsBackend::default();
2793 publish(&mut base, &old);
2794 publish(&mut base, ¤t);
2795 base.reset_faults();
2796
2797 let mut successful = base.clone();
2798 publish(&mut successful, &replacement);
2799 let mutation_count = successful.mutation_calls;
2800 assert!(mutation_count >= 7, "exercise stage, commit, and cleanup");
2801
2802 for crash_after in 1..=mutation_count {
2803 let mut crashed = base.clone();
2804 crashed.crash_after_mutation = Some(crash_after);
2805 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2806 let _ = publish_windows_value(&mut crashed, &replacement);
2807 }));
2808 assert!(unwind.is_err(), "mutation {crash_after} must crash");
2809 crashed.reset_faults();
2810
2811 let observed = read(&mut crashed);
2812 assert!(
2813 observed == current || observed == replacement,
2814 "crash {crash_after} exposed neither committed generation"
2815 );
2816
2817 publish(&mut crashed, &replacement);
2818 publish(&mut crashed, "recovery-pass");
2819 publish(&mut crashed, &replacement);
2820 assert_eq!(read(&mut crashed), replacement);
2821 assert!(
2822 crashed.entries.len() <= 20,
2823 "crash {crash_after} leaked unbounded entries: {:?}",
2824 crashed.entries.keys().collect::<Vec<_>>()
2825 );
2826 }
2827 }
2828
2829 #[test]
2830 fn repeated_precommit_crashes_have_bounded_cardinality_and_recover_cleanup() {
2831 let old = "old-".repeat(900);
2832 let attempted = "attempted-".repeat(900);
2833 let recovered = "ok-".repeat(600);
2834 let attempted_chunks = split_on_chars(&attempted, CHUNK_CHARS).len();
2835 let old_chunks = split_on_chars(&old, CHUNK_CHARS).len();
2836 let mut backend = MemoryWindowsBackend::default();
2837 publish(&mut backend, &old);
2838
2839 for crash_index in 0..64 {
2840 backend.reset_faults();
2841 backend.crash_after_mutation = Some(1 + crash_index % attempted_chunks);
2842 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2843 let _ = publish_windows_value(&mut backend, &attempted);
2844 }));
2845 assert!(
2846 backend.entries.len() <= 1 + 2 + old_chunks + attempted_chunks,
2847 "attempt {crash_index} grew deterministic storage"
2848 );
2849 }
2850
2851 backend.reset_faults();
2852 publish(&mut backend, &recovered);
2853 assert_eq!(read(&mut backend), recovered);
2854 let recovered_chunks = split_on_chars(&recovered, CHUNK_CHARS).len();
2855 assert!(!backend.entries.keys().any(|slot| matches!(
2856 slot,
2857 WindowsCredentialSlot::V3Chunk {
2858 generation: ChunkGeneration::B,
2859 index,
2860 } if *index >= recovered_chunks
2861 )));
2862 assert_eq!(
2863 backend
2864 .entries
2865 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::B)),
2866 Some(&recovered_chunks.to_string())
2867 );
2868 }
2869
2870 #[test]
2871 fn staging_and_root_failures_leave_the_only_good_generation_readable() {
2872 let old = "old-".repeat(900);
2873 let replacement = "replacement-".repeat(500);
2874 for failed_slot in [
2875 WindowsCredentialSlot::V3Chunk {
2876 generation: ChunkGeneration::B,
2877 index: 1,
2878 },
2879 WindowsCredentialSlot::Root,
2880 ] {
2881 let mut backend = MemoryWindowsBackend::default();
2882 publish(&mut backend, &old);
2883 backend.fail_write = Some(FailureRule {
2884 slot: failed_slot,
2885 matches_to_skip: 0,
2886 });
2887
2888 let error = publish_windows_value(&mut backend, &replacement).unwrap_err();
2889 assert_backend_error(error, "injected");
2890 assert_eq!(read(&mut backend), old);
2891 }
2892 }
2893
2894 #[test]
2895 fn postcommit_cleanup_errors_report_deferred_success_and_recover_later() {
2896 let old = "old-".repeat(1400);
2897 let current = "current-".repeat(900);
2898 let replacement = "replacement-".repeat(200);
2899 let mut backend = MemoryWindowsBackend::default();
2900 publish(&mut backend, &old);
2901 publish(&mut backend, ¤t);
2902 backend.fail_delete = Some(FailureRule {
2903 slot: WindowsCredentialSlot::V3Chunk {
2904 generation: ChunkGeneration::A,
2905 index: 4,
2906 },
2907 matches_to_skip: 0,
2908 });
2909
2910 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2911 assert_eq!(cleanup.failures, 1);
2912 assert_eq!(read(&mut backend), replacement);
2913 assert_eq!(
2914 backend
2915 .entries
2916 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2917 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string()),
2918 "failed cleanup keeps the crash high-water for a later sweep"
2919 );
2920
2921 publish(&mut backend, "rotate-once");
2922 publish(&mut backend, &replacement);
2923 assert!(!backend.entries.keys().any(|slot| matches!(
2924 slot,
2925 WindowsCredentialSlot::V3Chunk {
2926 generation: ChunkGeneration::A,
2927 index,
2928 } if *index >= split_on_chars(&replacement, CHUNK_CHARS).len()
2929 )));
2930 }
2931
2932 #[test]
2933 fn postcommit_manifest_shrink_failure_keeps_recovery_high_water() {
2934 let old = "old-".repeat(1400);
2935 let current = "current-".repeat(900);
2936 let replacement = "replacement-".repeat(200);
2937 let mut backend = MemoryWindowsBackend::default();
2938 publish(&mut backend, &old);
2939 publish(&mut backend, ¤t);
2940 backend.fail_write = Some(FailureRule {
2941 slot: WindowsCredentialSlot::V3Manifest(ChunkGeneration::A),
2942 matches_to_skip: 1,
2943 });
2944
2945 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2946 assert_eq!(cleanup.failures, 1);
2947 assert_eq!(read(&mut backend), replacement);
2948 assert_eq!(
2949 backend
2950 .entries
2951 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2952 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string())
2953 );
2954 }
2955
2956 #[test]
2957 fn delete_cleanup_failure_retains_manifest_for_idempotent_recovery() {
2958 let value = "secret-".repeat(700);
2959 let mut backend = MemoryWindowsBackend::default();
2960 publish(&mut backend, &value);
2961 backend.fail_delete = Some(FailureRule {
2962 slot: WindowsCredentialSlot::V3Chunk {
2963 generation: ChunkGeneration::A,
2964 index: 0,
2965 },
2966 matches_to_skip: 0,
2967 });
2968
2969 let cleanup = delete_windows_value(&mut backend).unwrap();
2970 assert_eq!(cleanup.failures, 1);
2971 assert!(!backend.entries.contains_key(&WindowsCredentialSlot::Root));
2972 assert!(backend
2973 .entries
2974 .contains_key(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)));
2975
2976 backend.reset_faults();
2977 assert_eq!(delete_windows_value(&mut backend).unwrap().failures, 0);
2978 assert!(backend.entries.is_empty());
2979 }
2980
2981 #[test]
2982 fn corrupt_cleanup_metadata_fails_before_root_or_chunks_are_deleted() {
2983 let old = "old-".repeat(900);
2984 let mut backend = MemoryWindowsBackend::default();
2985 publish(&mut backend, &old);
2986 let root_before = backend.root();
2987 backend.entries.insert(
2988 WindowsCredentialSlot::V3Manifest(ChunkGeneration::B),
2989 "not-a-count".to_string(),
2990 );
2991
2992 let error = publish_windows_value(&mut backend, "replacement").unwrap_err();
2993 assert_backend_error(error, "manifest");
2994 assert_eq!(backend.root(), root_before);
2995 assert_eq!(read(&mut backend), old);
2996
2997 let error = delete_windows_value(&mut backend).unwrap_err();
2998 assert_backend_error(error, "manifest");
2999 assert_eq!(backend.root(), root_before);
3000 assert_eq!(read(&mut backend), old);
3001 }
3002
3003 #[test]
3004 fn reader_retry_is_bounded_when_root_never_stabilizes() {
3005 let value_a = "a".repeat(2500);
3006 let value_b = "b".repeat(2500);
3007 let mut backend = MemoryWindowsBackend::default();
3008 publish(&mut backend, &value_a);
3009 let root_a = backend.root();
3010 publish(&mut backend, &value_b);
3011 let root_b = backend.root();
3012 backend.entries.remove(&WindowsCredentialSlot::V3Chunk {
3013 generation: ChunkGeneration::A,
3014 index: 0,
3015 });
3016 let mut churning = AlternatingRootBackend {
3017 inner: backend,
3018 roots: [root_a, root_b],
3019 root_reads: 0,
3020 };
3021
3022 let error = read_windows_value(&mut churning).unwrap_err();
3023 assert_backend_error(error, "changed during every read attempt");
3024 assert_eq!(churning.root_reads, WINDOWS_READ_ATTEMPTS * 2);
3025 }
3026
3027 struct InterleavingReader<'a> {
3028 inner: MemoryWindowsBackend,
3029 publications: Vec<&'a str>,
3030 root_reads: usize,
3031 }
3032
3033 impl<'a> InterleavingReader<'a> {
3034 fn new(inner: MemoryWindowsBackend, publications: Vec<&'a str>) -> Self {
3035 Self {
3036 inner,
3037 publications,
3038 root_reads: 0,
3039 }
3040 }
3041 }
3042
3043 impl WindowsCredentialBackend for InterleavingReader<'_> {
3044 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
3045 let captured = self.inner.read(slot)?;
3046 if slot == &WindowsCredentialSlot::Root && self.root_reads == 0 {
3047 for value in self.publications.drain(..) {
3048 publish_windows_value(&mut self.inner, value)?;
3049 }
3050 }
3051 if slot == &WindowsCredentialSlot::Root {
3052 self.root_reads += 1;
3053 }
3054 Ok(captured)
3055 }
3056
3057 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
3058 self.inner.write(slot, value)
3059 }
3060
3061 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
3062 self.inner.delete(slot)
3063 }
3064 }
3065
3066 struct AlternatingRootBackend {
3067 inner: MemoryWindowsBackend,
3068 roots: [String; 2],
3069 root_reads: usize,
3070 }
3071
3072 impl WindowsCredentialBackend for AlternatingRootBackend {
3073 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
3074 if slot == &WindowsCredentialSlot::Root {
3075 let root = self.roots[self.root_reads % self.roots.len()].clone();
3076 self.root_reads += 1;
3077 return Ok(Some(root));
3078 }
3079 self.inner.read(slot)
3080 }
3081
3082 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
3083 self.inner.write(slot, value)
3084 }
3085
3086 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
3087 self.inner.delete(slot)
3088 }
3089 }
3090}
3091
3092#[cfg(test)]
3093mod tests {
3094 use super::*;
3095 use serde::{Deserialize, Serialize};
3096
3097 #[test]
3108 fn a_store_that_is_not_a_directory_is_a_backend_error_not_a_missing_secret() {
3109 let parent = tempfile::tempdir().unwrap();
3110 let not_a_dir = parent.path().join("blocked");
3111 std::fs::write(¬_a_dir, b"a regular file where the store should be").unwrap();
3112 let reference = SecretRef::with_default_service("SOME_KEY");
3113
3114 assert!(
3115 !file_backend_entry_is_merely_absent(¬_a_dir),
3116 "the platform-neutral discriminator must reject a regular-file store root"
3117 );
3118
3119 match file_backend_get(¬_a_dir, &reference) {
3120 Err(SecretError::Backend(_)) => {}
3121 other => panic!("unusable store must report a backend error, got {other:?}"),
3122 }
3123 match file_backend_delete(¬_a_dir, &reference) {
3124 Err(SecretError::Backend(_)) => {}
3125 other => panic!("unusable store must not report a successful delete, got {other:?}"),
3126 }
3127 assert!(
3128 !file_backend_status(¬_a_dir, &reference).exists,
3129 "status on an unusable store must not claim knowledge of the entry"
3130 );
3131 }
3132
3133 #[test]
3136 fn a_store_directory_that_does_not_exist_yet_is_still_not_found() {
3137 let parent = tempfile::tempdir().unwrap();
3138 let never_created = parent.path().join("not-created-yet");
3139 assert!(!never_created.exists());
3140 assert!(
3141 file_backend_entry_is_merely_absent(&never_created),
3142 "a missing directory beneath an existing directory is a normal first run"
3143 );
3144 let reference = SecretRef::with_default_service("SOME_KEY");
3145
3146 match file_backend_get(&never_created, &reference) {
3147 Err(SecretError::NotFound { .. }) => {}
3148 other => panic!("a first-run store has no secrets, it is not broken: {other:?}"),
3149 }
3150 assert!(
3151 file_backend_delete(&never_created, &reference).is_ok(),
3152 "deleting from a store that was never written is a no-op success"
3153 );
3154 assert!(!file_backend_status(&never_created, &reference).exists);
3155 }
3156
3157 #[test]
3158 fn a_missing_entry_in_a_real_directory_is_still_not_found() {
3159 let dir = tempfile::tempdir().unwrap();
3160 let reference = SecretRef::with_default_service("ABSENT_KEY");
3161
3162 match file_backend_get(dir.path(), &reference) {
3163 Err(SecretError::NotFound { .. }) => {}
3164 other => panic!("an absent entry in a usable store is NotFound, got {other:?}"),
3165 }
3166 assert!(
3167 file_backend_delete(dir.path(), &reference).is_ok(),
3168 "deleting an absent entry from a usable store is a no-op success"
3169 );
3170 assert!(!file_backend_status(dir.path(), &reference).exists);
3171 }
3172
3173 static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3184
3185 fn lock_store() -> std::sync::MutexGuard<'static, ()> {
3186 STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
3187 }
3188
3189 fn test_service() -> String {
3194 format!(
3195 "car-secrets-tests-{}-{}",
3196 std::process::id(),
3197 std::time::SystemTime::now()
3200 .duration_since(std::time::UNIX_EPOCH)
3201 .map(|d| d.as_nanos())
3202 .unwrap_or(0)
3203 )
3204 }
3205
3206 fn skip_if_unavailable() -> bool {
3207 !SecretStore::new().is_available()
3208 }
3209
3210 #[derive(Clone)]
3225 struct BufWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3226
3227 impl std::io::Write for BufWriter {
3228 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3229 self.0.lock().unwrap().extend_from_slice(buf);
3230 Ok(buf.len())
3231 }
3232 fn flush(&mut self) -> std::io::Result<()> {
3233 Ok(())
3234 }
3235 }
3236
3237 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
3238 type Writer = BufWriter;
3239 fn make_writer(&'a self) -> Self::Writer {
3240 self.clone()
3241 }
3242 }
3243
3244 #[test]
3245 fn file_backend_roundtrip_and_warn_under_debug() {
3246 const CHILD: &str = "CAR_TEST_FILE_BACKEND_WARNING_CHILD";
3247 if std::env::var_os(CHILD).is_none() {
3248 let output =
3249 std::process::Command::new(std::env::current_exe().expect("test executable"))
3250 .args([
3251 "--exact",
3252 "tests::file_backend_roundtrip_and_warn_under_debug",
3253 "--nocapture",
3254 ])
3255 .env(CHILD, "1")
3256 .env_remove("CAR_SECRETS_FILE_DIR")
3257 .env_remove("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING")
3258 .env_remove("CAR_KEYCHAIN_PROOF_ROOT")
3259 .env_remove("CAR_KEYCHAIN_PATH")
3260 .output()
3261 .expect("spawn isolated file-backend warning test");
3262 assert!(
3263 output.status.success(),
3264 "isolated file-backend warning test failed\nstdout:\n{}\nstderr:\n{}",
3265 String::from_utf8_lossy(&output.stdout),
3266 String::from_utf8_lossy(&output.stderr),
3267 );
3268 return;
3269 }
3270
3271 if std::env::var_os("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING").is_some() {
3275 let _ = file_backend_dir();
3276 }
3277 let _guard = lock_store();
3280 #[allow(clippy::assertions_on_constants)]
3284 {
3285 assert!(
3286 cfg!(debug_assertions),
3287 "the crate test suite runs in debug; the file backend depends on it"
3288 );
3289 }
3290
3291 let dir = std::env::temp_dir().join(format!(
3292 "car-secrets-filebackend-{}-{}",
3293 std::process::id(),
3294 std::time::SystemTime::now()
3295 .duration_since(std::time::UNIX_EPOCH)
3296 .map(|d| d.as_nanos())
3297 .unwrap_or(0)
3298 ));
3299 std::fs::create_dir_all(&dir).unwrap();
3300 std::env::set_var("CAR_SECRETS_FILE_DIR", &dir);
3301
3302 let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
3305 let subscriber = tracing_subscriber::fmt()
3306 .with_writer(BufWriter(buf.clone()))
3307 .with_max_level(tracing::Level::WARN)
3308 .finish();
3309 tracing::subscriber::with_default(subscriber, || {
3310 assert_eq!(
3313 file_backend_dir().as_deref(),
3314 Some(dir.as_path()),
3315 "CAR_SECRETS_FILE_DIR must be honored under debug_assertions"
3316 );
3317 });
3318 let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3319 assert!(
3320 logged.contains("PLAINTEXT ON DISK"),
3321 "the file backend must emit the one-time PLAINTEXT warning, got logs: {logged:?}"
3322 );
3323
3324 let store = SecretStore::new();
3325 let check = store.availability();
3327 assert!(check.available, "file backend must report available");
3328 assert!(check.reason.is_none());
3329
3330 let r = SecretRef::new("svc", "key");
3332 store.put(&r, "xoxb-plaintext-value").unwrap();
3333 assert_eq!(store.get(&r).unwrap(), "xoxb-plaintext-value");
3334 let on_disk = std::fs::read_to_string(file_backend_path(&dir, &r)).unwrap();
3336 assert_eq!(on_disk, "xoxb-plaintext-value");
3337 store.delete(&r).unwrap();
3338 match store.get(&r) {
3339 Err(SecretError::NotFound { .. }) => {}
3340 other => panic!("expected NotFound after delete, got {other:?}"),
3341 }
3342
3343 for key in [
3347 OPENROUTER_OAUTH_KEY,
3348 PARSLEE_ACCESS_TOKEN_KEY,
3349 PARSLEE_REFRESH_TOKEN_KEY,
3350 PARSLEE_EXPIRES_AT_KEY,
3351 PARSLEE_API_BASE_KEY,
3352 PARSLEE_ACCOUNTS_KEY,
3353 "PARSLEE_TOKENS_account-1",
3354 PARSLEE_AUTH_GENERATION_KEY,
3355 PARSLEE_AUTH_COMPLETION_KEY,
3356 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3357 PARSLEE_AUTH_STATE_V2_KEY,
3358 ] {
3359 let private = SecretRef::new(DEFAULT_SERVICE, key);
3360 assert!(is_daemon_private_secret(&private.service, &private.key));
3361 store.put(&private, "internal-test-value").unwrap();
3362 assert_eq!(store.get(&private).unwrap(), "internal-test-value");
3363 store.delete(&private).unwrap();
3364 assert!(matches!(
3365 store.get(&private),
3366 Err(SecretError::NotFound { .. })
3367 ));
3368 }
3369
3370 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3372 let _ = std::fs::remove_dir_all(&dir);
3373 }
3374
3375 #[test]
3376 fn every_parslee_auth_slot_is_private_to_the_dedicated_auth_surface() {
3377 for key in [
3378 PARSLEE_ACCESS_TOKEN_KEY,
3379 PARSLEE_REFRESH_TOKEN_KEY,
3380 PARSLEE_EXPIRES_AT_KEY,
3381 PARSLEE_API_BASE_KEY,
3382 PARSLEE_ACCOUNTS_KEY,
3383 "PARSLEE_TOKENS_account-1",
3384 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3385 PARSLEE_AUTH_GENERATION_KEY,
3386 PARSLEE_AUTH_COMPLETION_KEY,
3387 PARSLEE_AUTH_STATE_V2_KEY,
3388 ] {
3389 assert!(
3390 is_daemon_private_secret(DEFAULT_SERVICE, key),
3391 "{key} must be unreachable through generic secret surfaces"
3392 );
3393 assert!(
3394 !is_daemon_private_secret("other-service", key),
3395 "reservation must remain scoped to the CAR service"
3396 );
3397 }
3398
3399 assert!(!is_daemon_private_secret(
3400 DEFAULT_SERVICE,
3401 "OPENROUTER_API_KEY"
3402 ));
3403 for key in [
3404 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunk0"),
3405 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunkv2#nonce-1#0"),
3406 format!("{OPENROUTER_OAUTH_KEY}#chunk17"),
3407 format!("{OPENROUTER_OAUTH_KEY}#chunkv2#nonce-2#3"),
3408 ] {
3409 assert!(
3410 is_daemon_private_secret(DEFAULT_SERVICE, &key),
3411 "{key} is derived from a daemon-private root"
3412 );
3413 assert!(!is_daemon_private_secret("other-service", &key));
3414 }
3415 }
3416
3417 #[cfg(target_os = "macos")]
3418 #[test]
3419 fn bounded_command_output_large_helper() {
3420 if std::env::var_os("CAR_SECURITY_OUTPUT_HELPER").is_none() {
3421 return;
3422 }
3423 use std::io::Write;
3424 let payload = vec![b'x'; 128 * 1024];
3425 std::io::stdout().write_all(&payload).unwrap();
3426 std::io::stdout().flush().unwrap();
3427 std::io::stderr().write_all(&payload).unwrap();
3428 std::io::stderr().flush().unwrap();
3429 }
3430
3431 #[cfg(target_os = "macos")]
3432 #[test]
3433 fn bounded_command_output_drains_large_stdout_and_stderr() {
3434 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3435 command
3436 .args([
3437 "--exact",
3438 "tests::bounded_command_output_large_helper",
3439 "--nocapture",
3440 ])
3441 .env("CAR_SECURITY_OUTPUT_HELPER", "1");
3442
3443 let output =
3444 bounded_command_output(&mut command, std::time::Duration::from_secs(5), "test")
3445 .unwrap();
3446
3447 assert!(output.output.status.success(), "{output:?}");
3448 assert!(output.output.stdout.len() >= 128 * 1024);
3449 assert!(output.output.stderr.len() >= 128 * 1024);
3450 }
3451
3452 #[cfg(target_os = "macos")]
3453 pub(super) struct FakeSecurityCli {
3454 outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
3455 calls: std::cell::RefCell<Vec<Vec<String>>>,
3456 }
3457
3458 #[cfg(target_os = "macos")]
3459 impl FakeSecurityCli {
3460 pub(super) fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
3461 Self {
3462 outputs: std::cell::RefCell::new(outputs.into()),
3463 calls: std::cell::RefCell::new(Vec::new()),
3464 }
3465 }
3466
3467 pub(super) fn calls(&self) -> Vec<Vec<String>> {
3468 self.calls.borrow().clone()
3469 }
3470 }
3471
3472 #[cfg(target_os = "macos")]
3473 impl SecurityCli for FakeSecurityCli {
3474 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
3475 self.calls
3476 .borrow_mut()
3477 .push(args.iter().map(|arg| (*arg).to_string()).collect());
3478 self.outputs
3479 .borrow_mut()
3480 .pop_front()
3481 .expect("missing fake security output")
3482 }
3483 }
3484
3485 #[cfg(target_os = "macos")]
3486 pub(super) fn security_output(
3487 code: i32,
3488 stdout: impl Into<Vec<u8>>,
3489 stderr: impl Into<Vec<u8>>,
3490 ) -> std::io::Result<SecurityCliOutput> {
3491 Ok(SecurityCliOutput {
3492 success: code == 0,
3493 code: Some(code),
3494 stdout: stdout.into(),
3495 stderr: stderr.into(),
3496 prompted: false,
3497 timed_out: false,
3498 })
3499 }
3500
3501 #[cfg(target_os = "macos")]
3504 pub(super) fn security_output_prompted(
3505 code: i32,
3506 stdout: impl Into<Vec<u8>>,
3507 stderr: impl Into<Vec<u8>>,
3508 ) -> std::io::Result<SecurityCliOutput> {
3509 let mut out = security_output(code, stdout, stderr)?;
3510 out.prompted = true;
3511 Ok(out)
3512 }
3513
3514 #[cfg(target_os = "macos")]
3515 fn args(values: &[&str]) -> Vec<String> {
3516 values.iter().map(|value| (*value).to_string()).collect()
3517 }
3518
3519 #[cfg(target_os = "macos")]
3523 #[test]
3524 fn availability_probe_goes_through_the_security_helper() {
3525 let cli = FakeSecurityCli::new(vec![security_output(0, "", "")]);
3526 let check = mac_availability_via_security_cli_with(&cli);
3527
3528 assert!(check.available);
3529 assert_eq!(
3530 cli.calls(),
3531 vec![args(&[
3532 "find-generic-password",
3533 "-s",
3534 "car-internal",
3535 "-a",
3536 "__availability_probe__",
3537 ])]
3538 );
3539 assert!(
3543 !cli.calls()[0].iter().any(|arg| arg == "-g"),
3544 "availability probe must not read password bytes: {:?}",
3545 cli.calls()[0]
3546 );
3547 }
3548
3549 #[cfg(target_os = "macos")]
3553 #[test]
3554 fn availability_probe_absent_item_is_still_reachable() {
3555 let cli = FakeSecurityCli::new(vec![security_output(
3556 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3557 "",
3558 "",
3559 )]);
3560 let check = mac_availability_via_security_cli_with(&cli);
3561
3562 assert!(check.available);
3563 assert!(check.reason.is_none(), "{:?}", check.reason);
3564 }
3565
3566 #[cfg(target_os = "macos")]
3570 #[test]
3571 fn availability_probe_backend_error_reports_unavailable() {
3572 let cli = FakeSecurityCli::new(vec![security_output(
3573 51,
3574 "",
3575 "security: SecKeychainSearchCopyNext: User interaction is not allowed.",
3576 )]);
3577 let check = mac_availability_via_security_cli_with(&cli);
3578
3579 assert!(!check.available);
3580 let reason = check.reason.expect("unavailable must carry a reason");
3581 assert!(
3582 reason.contains("User interaction is not allowed"),
3583 "reason should carry the helper's stderr, got {reason:?}"
3584 );
3585 }
3586
3587 #[cfg(target_os = "macos")]
3592 #[test]
3593 fn availability_probe_names_itself_in_the_prompt_notice() {
3594 let cli = FakeSecurityCli::new(vec![security_output(
3595 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3596 "",
3597 "",
3598 )]);
3599 let _ = mac_availability_via_security_cli_with(&cli);
3600
3601 let sent = cli.calls().remove(0);
3607 let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
3608 let item = describe_item(&sent);
3609
3610 assert_eq!(item, "car-internal/__availability_probe__");
3611 assert!(
3612 keychain_prompt_notice(&item).contains(&item),
3613 "notice must name the blocking item: {}",
3614 keychain_prompt_notice(&item)
3615 );
3616 }
3617
3618 #[cfg(target_os = "macos")]
3619 fn assert_access_denied_contains(err: SecretError, expected: &str) {
3620 match err {
3621 SecretError::AccessDenied { message } => assert!(
3622 message.contains(expected),
3623 "expected access-denied error to contain {expected:?}, got {message:?}"
3624 ),
3625 other => panic!("expected AccessDenied, got {:?}", other),
3626 }
3627 }
3628
3629 #[cfg(target_os = "macos")]
3630 #[test]
3631 fn mac_security_errors_are_typed_for_recovery() {
3632 assert!(matches!(
3633 classify_security_error(-128, "user canceled"),
3634 SecretError::UserCancelled { .. }
3635 ));
3636 assert!(matches!(
3637 classify_security_error(-25293, "authorization denied"),
3638 SecretError::AccessDenied { .. }
3639 ));
3640 assert!(matches!(
3641 classify_helper_timeout("car/PARSLEE_AUTH_STATE_V2"),
3642 SecretError::HelperTimedOut { .. }
3643 ));
3644
3645 let mut timed_out = security_output(9, b"", b"helper killed").unwrap();
3646 timed_out.timed_out = true;
3647 let cli = FakeSecurityCli::new(vec![Ok(timed_out)]);
3648 let secret = SecretRef::new("svc", "key");
3649 assert!(matches!(
3650 mac_get_via_security_cli_with(&secret, &cli),
3651 Err(SecretError::HelperTimedOut { .. })
3652 ));
3653 }
3654
3655 #[cfg(target_os = "macos")]
3656 struct IsolatedKeychainFixture {
3657 _temp: tempfile::TempDir,
3658 proof_root: std::path::PathBuf,
3659 valid_path: std::path::PathBuf,
3660 symlink_path: std::path::PathBuf,
3661 outside_path: std::path::PathBuf,
3662 public_path: std::path::PathBuf,
3663 directory_path: std::path::PathBuf,
3664 public_root: std::path::PathBuf,
3665 }
3666
3667 #[cfg(target_os = "macos")]
3668 impl IsolatedKeychainFixture {
3669 fn new() -> Self {
3670 use std::os::unix::fs::{symlink, PermissionsExt};
3671
3672 let temp = tempfile::tempdir().unwrap();
3673 let proof_root = temp.path().join("proof");
3674 std::fs::create_dir(&proof_root).unwrap();
3675 std::fs::set_permissions(&proof_root, std::fs::Permissions::from_mode(0o700)).unwrap();
3676
3677 let valid_path = proof_root.join("valid.keychain-db");
3678 std::fs::write(&valid_path, b"keychain fixture").unwrap();
3679 std::fs::set_permissions(&valid_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3680
3681 let symlink_path = proof_root.join("linked.keychain-db");
3682 symlink(&valid_path, &symlink_path).unwrap();
3683
3684 let outside_path = temp.path().join("outside.keychain-db");
3685 std::fs::write(&outside_path, b"outside fixture").unwrap();
3686 std::fs::set_permissions(&outside_path, std::fs::Permissions::from_mode(0o600))
3687 .unwrap();
3688
3689 let public_path = proof_root.join("public.keychain-db");
3690 std::fs::write(&public_path, b"public fixture").unwrap();
3691 std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3692
3693 let directory_path = proof_root.join("directory.keychain-db");
3694 std::fs::create_dir(&directory_path).unwrap();
3695
3696 let public_root = temp.path().join("public-proof");
3697 std::fs::create_dir(&public_root).unwrap();
3698 std::fs::set_permissions(&public_root, std::fs::Permissions::from_mode(0o755)).unwrap();
3699
3700 Self {
3701 _temp: temp,
3702 proof_root,
3703 valid_path,
3704 symlink_path,
3705 outside_path,
3706 public_path,
3707 directory_path,
3708 public_root,
3709 }
3710 }
3711
3712 fn proof_root(&self) -> &std::path::Path {
3713 &self.proof_root
3714 }
3715
3716 fn valid_path(&self) -> &std::path::Path {
3717 &self.valid_path
3718 }
3719
3720 fn symlink_path(&self) -> &std::path::Path {
3721 &self.symlink_path
3722 }
3723
3724 fn outside_path(&self) -> &std::path::Path {
3725 &self.outside_path
3726 }
3727
3728 fn public_path(&self) -> &std::path::Path {
3729 &self.public_path
3730 }
3731
3732 fn directory_path(&self) -> &std::path::Path {
3733 &self.directory_path
3734 }
3735
3736 fn public_root(&self) -> &std::path::Path {
3737 &self.public_root
3738 }
3739 }
3740
3741 #[cfg(target_os = "macos")]
3742 #[test]
3743 fn isolated_keychain_must_be_absolute_private_regular_owned_and_under_proof_root() {
3744 let fixture = IsolatedKeychainFixture::new();
3745 assert!(validate_keychain_path(fixture.valid_path(), fixture.proof_root()).is_ok());
3746 assert!(validate_keychain_path(
3747 std::path::Path::new("relative.keychain-db"),
3748 fixture.proof_root()
3749 )
3750 .is_err());
3751 assert!(validate_keychain_path(fixture.symlink_path(), fixture.proof_root()).is_err());
3752 assert!(validate_keychain_path(fixture.outside_path(), fixture.proof_root()).is_err());
3753 assert!(validate_keychain_path(fixture.public_path(), fixture.proof_root()).is_err());
3754 assert!(validate_keychain_path(fixture.directory_path(), fixture.proof_root()).is_err());
3755 assert!(validate_keychain_path(fixture.valid_path(), fixture.public_root()).is_err());
3756 }
3757
3758 #[test]
3759 fn secret_store_activity_counts_only_aggregate_public_operation_attempts() {
3760 let _guard = lock_store();
3761 let dir = tempfile::tempdir().unwrap();
3762 std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3763 let before = secret_store_activity();
3764 let store = SecretStore::new();
3765 let secret = SecretRef::new("activity-test", "credential");
3766
3767 assert!(store.availability().available);
3768 store.put(&secret, "sensitive-value").unwrap();
3769 let _ = store.get(&secret).unwrap();
3770 let _ = store.status(&secret).unwrap();
3771 store.publish(&secret, "replacement-value").unwrap();
3772 store.delete(&secret).unwrap();
3773
3774 let after = secret_store_activity();
3775 assert_eq!(after.get_attempts - before.get_attempts, 1);
3776 assert_eq!(after.status_attempts - before.status_attempts, 1);
3777 assert_eq!(
3778 after.availability_attempts - before.availability_attempts,
3779 1
3780 );
3781 assert_eq!(after.write_attempts - before.write_attempts, 2);
3782 assert_eq!(after.delete_attempts - before.delete_attempts, 1);
3783
3784 let encoded = serde_json::to_string(&after).unwrap();
3785 assert!(!encoded.contains("activity-test"));
3786 assert!(!encoded.contains("credential"));
3787 assert!(!encoded.contains("sensitive-value"));
3788 assert!(!encoded.contains(dir.path().to_string_lossy().as_ref()));
3789 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3790 }
3791
3792 #[test]
3793 fn roundtrip_string() {
3794 let _guard = lock_store();
3795 if skip_if_unavailable() {
3796 eprintln!("skipping: no secret store backend available");
3797 return;
3798 }
3799 let store = SecretStore::new();
3800 let svc = test_service();
3801 let r = SecretRef::new(&svc, "roundtrip");
3802 store.put(&r, "hello world").unwrap();
3803 assert_eq!(store.get(&r).unwrap(), "hello world");
3804 assert!(store.status(&r).unwrap().exists);
3805 store.delete(&r).unwrap();
3806 assert!(!store.status(&r).unwrap().exists);
3807 }
3808
3809 #[test]
3810 fn roundtrip_string_with_trailing_newline() {
3811 let _guard = lock_store();
3812 if skip_if_unavailable() {
3813 eprintln!("skipping: no secret store backend available");
3814 return;
3815 }
3816 let store = SecretStore::new();
3817 let svc = test_service();
3818 let r = SecretRef::new(&svc, "roundtrip-newline");
3819 let value = "abc\n";
3820 store.put(&r, value).unwrap();
3821 assert_eq!(store.get(&r).unwrap(), value);
3822 store.delete(&r).unwrap();
3823 }
3824
3825 #[test]
3826 fn get_missing_returns_not_found() {
3827 let _guard = lock_store();
3828 if skip_if_unavailable() {
3829 return;
3830 }
3831 let store = SecretStore::new();
3832 let r = SecretRef::new(test_service(), "never_written");
3833 match store.get(&r) {
3834 Err(SecretError::NotFound { .. }) => (),
3835 other => panic!("expected NotFound, got {:?}", other),
3836 }
3837 }
3838
3839 #[test]
3840 fn delete_missing_is_idempotent() {
3841 let _guard = lock_store();
3842 if skip_if_unavailable() {
3843 return;
3844 }
3845 let store = SecretStore::new();
3846 let r = SecretRef::new(test_service(), "missing");
3847 store.delete(&r).unwrap();
3849 store.delete(&r).unwrap();
3850 }
3851
3852 #[test]
3853 fn json_roundtrip() {
3854 let _guard = lock_store();
3855 if skip_if_unavailable() {
3856 return;
3857 }
3858 #[derive(Serialize, Deserialize, PartialEq, Debug)]
3859 struct Session {
3860 cookies: Vec<String>,
3861 expires_at: i64,
3862 }
3863 let store = SecretStore::new();
3864 let svc = test_service();
3865 let r = SecretRef::new(&svc, "session");
3866 let s = Session {
3867 cookies: vec!["a=1".into(), "b=2".into()],
3868 expires_at: 1_700_000_000,
3869 };
3870 store.put_json(&r, &s).unwrap();
3871 let back: Session = store.get_json(&r).unwrap();
3872 assert_eq!(back, s);
3873 store.delete(&r).unwrap();
3874 }
3875
3876 #[test]
3877 fn status_no_leak() {
3878 let _guard = lock_store();
3879 if skip_if_unavailable() {
3880 return;
3881 }
3882 let store = SecretStore::new();
3883 let r = SecretRef::new(test_service(), "status");
3884 store.put(&r, "secret-payload").unwrap();
3885 let st = store.status(&r).unwrap();
3886 let encoded = serde_json::to_string(&st).unwrap();
3888 assert!(!encoded.contains("secret-payload"));
3889 store.delete(&r).unwrap();
3890 }
3891
3892 #[cfg(target_os = "macos")]
3893 #[test]
3894 fn mac_get_uses_security_cli_and_maps_success() {
3895 let cli = FakeSecurityCli::new(vec![security_output(
3896 0,
3897 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3898 b"password: \"secret\"\n",
3899 )]);
3900 let r = SecretRef::new("svc", "key");
3901
3902 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
3903 assert_eq!(
3904 cli.calls(),
3905 vec![args(&[
3906 "find-generic-password",
3907 "-s",
3908 "svc",
3909 "-a",
3910 "key",
3911 "-g"
3912 ])]
3913 );
3914 }
3915
3916 #[cfg(target_os = "macos")]
3917 #[test]
3918 fn mac_get_decodes_hex_password_output_with_trailing_newline() {
3919 let cli = FakeSecurityCli::new(vec![security_output(
3920 0,
3921 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3922 b"password: 0x6162630A \"abc\\012\"\n",
3923 )]);
3924 let r = SecretRef::new("svc", "key");
3925
3926 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
3927 assert_eq!(
3928 cli.calls(),
3929 vec![args(&[
3930 "find-generic-password",
3931 "-s",
3932 "svc",
3933 "-a",
3934 "key",
3935 "-g"
3936 ])]
3937 );
3938 }
3939
3940 #[cfg(target_os = "macos")]
3941 #[test]
3942 fn mac_get_maps_not_found_and_access_denied_without_fallback() {
3943 let r = SecretRef::new("svc", "missing");
3944 let cli = FakeSecurityCli::new(vec![security_output(
3945 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3946 b"",
3947 b"The specified item could not be found in the keychain.\n",
3948 )]);
3949
3950 match mac_get_via_security_cli_with(&r, &cli) {
3951 Err(SecretError::NotFound { service, key }) => {
3952 assert_eq!(service, "svc");
3953 assert_eq!(key, "missing");
3954 }
3955 other => panic!("expected NotFound, got {:?}", other),
3956 }
3957 assert_eq!(cli.calls().len(), 1);
3958
3959 let cli = FakeSecurityCli::new(vec![security_output(
3960 51,
3961 b"",
3962 b"User interaction is not allowed.\n",
3963 )]);
3964 let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
3965 assert_access_denied_contains(err, "User interaction is not allowed.");
3966 assert_eq!(cli.calls().len(), 1);
3967 }
3968
3969 #[cfg(target_os = "macos")]
3970 #[test]
3971 fn mac_status_uses_security_cli_and_maps_results() {
3972 let r = SecretRef::new("svc", "key");
3973 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
3974
3975 let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
3976 assert!(status.exists);
3977 assert_eq!(
3978 cli.calls(),
3979 vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
3980 );
3981
3982 let cli = FakeSecurityCli::new(vec![security_output(
3983 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3984 b"",
3985 b"The specified item could not be found in the keychain.\n",
3986 )]);
3987 assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
3988
3989 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
3990 let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
3991 assert_access_denied_contains(err, "auth denied");
3992 }
3993
3994 #[cfg(target_os = "macos")]
3995 #[test]
3996 fn mac_put_pre_deletes_then_adds_so_acl_is_fresh() {
3997 let cli = FakeSecurityCli::new(vec![
4004 security_output(
4006 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4007 b"",
4008 b"The specified item could not be found in the keychain.\n",
4009 ),
4010 security_output(0, b"", b""),
4012 ]);
4013
4014 mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4015
4016 assert_eq!(
4017 cli.calls(),
4018 vec![
4019 args(&["delete-generic-password", "-s", "svc", "-a", "key"]),
4020 args(&[
4021 "add-generic-password",
4022 "-U",
4023 "-A",
4024 "-s",
4025 "svc",
4026 "-a",
4027 "key",
4028 "-w",
4029 "secret",
4030 ]),
4031 ]
4032 );
4033 }
4034
4035 #[cfg(target_os = "macos")]
4036 #[test]
4037 fn mac_put_ignores_pre_delete_failure_and_still_adds() {
4038 let cli = FakeSecurityCli::new(vec![
4043 security_output(128, b"", b"some weird backend error\n"),
4044 security_output(0, b"", b""),
4045 ]);
4046
4047 mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4048
4049 assert_eq!(cli.calls().len(), 2);
4050 assert_eq!(
4051 cli.calls()[1],
4052 args(&[
4053 "add-generic-password",
4054 "-U",
4055 "-A",
4056 "-s",
4057 "svc",
4058 "-a",
4059 "key",
4060 "-w",
4061 "secret",
4062 ])
4063 );
4064 }
4065
4066 #[cfg(target_os = "macos")]
4067 #[test]
4068 fn mac_put_surfaces_add_failure_as_access_denied() {
4069 let cli = FakeSecurityCli::new(vec![
4070 security_output(0, b"", b""),
4071 security_output(51, b"", b"User interaction is not allowed.\n"),
4072 ]);
4073
4074 let err = mac_put_via_security_cli_with("svc", "key", "secret", &cli).unwrap_err();
4075 assert_access_denied_contains(err, "User interaction is not allowed.");
4076 }
4077
4078 #[cfg(target_os = "macos")]
4079 #[test]
4080 fn mac_publish_updates_in_place_without_a_pre_delete_gap() {
4081 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4082
4083 mac_publish_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4084
4085 assert_eq!(
4086 cli.calls(),
4087 vec![args(&[
4088 "add-generic-password",
4089 "-U",
4090 "-A",
4091 "-s",
4092 "svc",
4093 "-a",
4094 "key",
4095 "-w",
4096 "secret",
4097 ])]
4098 );
4099 }
4100
4101 #[cfg(target_os = "macos")]
4102 #[test]
4103 fn mac_security_child_is_killed_and_reaped_at_its_deadline() {
4104 let mut command = std::process::Command::new("/bin/sh");
4105 command.args(["-c", "sleep 5"]);
4106 let started = std::time::Instant::now();
4107
4108 let output =
4109 bounded_command_output(&mut command, std::time::Duration::from_millis(40), "test")
4110 .unwrap();
4111
4112 assert!(!output.output.status.success());
4113 assert!(
4114 started.elapsed() < std::time::Duration::from_secs(1),
4115 "bounded helper must not wait for the child command's natural exit"
4116 );
4117 let stderr = String::from_utf8_lossy(&output.output.stderr);
4118 assert!(stderr.contains("CAR killed the keychain helper"));
4119 assert!(
4125 stderr.contains("keychain prompt"),
4126 "the timeout must name a pending keychain prompt as the likely cause"
4127 );
4128 assert!(
4129 stderr.contains("Always Allow"),
4130 "the timeout must tell the user what action clears it"
4131 );
4132 }
4133
4134 #[cfg(target_os = "macos")]
4143 #[test]
4144 fn a_hung_helper_with_no_dialog_still_dies_at_the_short_deadline() {
4145 assert!(
4148 SECURITY_CLI_INTERACTIVE_TIMEOUT > SECURITY_CLI_TIMEOUT,
4149 "the interactive allowance must be longer than the hang deadline"
4150 );
4151 assert!(
4152 SECURITY_CLI_INTERACTIVE_TIMEOUT >= std::time::Duration::from_secs(60),
4153 "a human needs to find a window, type a password and submit — 15s \
4154 is why entering the correct password repeatedly never worked"
4155 );
4156
4157 let mut command = std::process::Command::new("/bin/sh");
4158 command.args(["-c", "sleep 5"]);
4159 let started = std::time::Instant::now();
4160 let output =
4161 bounded_command_output(&mut command, std::time::Duration::from_millis(40), "test")
4162 .unwrap();
4163 assert!(!output.output.status.success());
4164 assert!(
4165 started.elapsed() < std::time::Duration::from_secs(1),
4166 "a helper with no dialog must not inherit the interactive allowance"
4167 );
4168 }
4169
4170 #[cfg(target_os = "macos")]
4180 #[test]
4181 fn a_dialog_is_not_attributed_to_a_read_that_did_not_wait_for_it() {
4182 let instant = std::time::Duration::from_millis(0);
4183 let quick = std::time::Duration::from_millis(20);
4184
4185 assert!(
4186 !dialog_is_evidence_for_this_read(true, instant),
4187 "a dialog already on screen at spawn belongs to whatever opened it"
4188 );
4189 assert!(
4190 !dialog_is_evidence_for_this_read(true, quick),
4191 "a read that returned in 20ms was never blocked on a human"
4192 );
4193 assert!(
4194 !dialog_is_evidence_for_this_read(false, std::time::Duration::from_secs(60)),
4195 "no dialog is no evidence, however long the helper took"
4196 );
4197 assert!(
4198 dialog_is_evidence_for_this_read(true, PROMPT_EVIDENCE_MIN),
4199 "a call still blocked with a dialog up is the one being authorized"
4200 );
4201 }
4202
4203 #[cfg(target_os = "macos")]
4206 #[test]
4207 fn the_prompt_evidence_threshold_sits_between_a_silent_read_and_a_human() {
4208 assert!(
4209 PROMPT_EVIDENCE_MIN >= std::time::Duration::from_millis(200),
4210 "must be an order of magnitude above a silent `security -g` read, \
4211 which returns in tens of milliseconds"
4212 );
4213 assert!(
4214 PROMPT_EVIDENCE_MIN <= std::time::Duration::from_secs(2),
4215 "must stay below the fastest a human can answer a dialog, or a real \
4216 stale-ACL read never gets repaired and prompts forever"
4217 );
4218 assert!(
4219 PROMPT_EVIDENCE_MIN < SECURITY_CLI_TIMEOUT,
4220 "a prompted read must be attributable before any deadline can end it"
4221 );
4222 }
4223
4224 #[cfg(target_os = "macos")]
4233 #[test]
4234 fn a_fast_helper_is_not_attributed_a_dialog_that_is_on_screen_throughout() {
4235 let mut command = std::process::Command::new("/bin/echo");
4236 command.arg("hi");
4237 let notices = std::sync::atomic::AtomicUsize::new(0);
4238 let run = bounded_command_output_with(
4239 &mut command,
4240 SECURITY_CLI_TIMEOUT,
4241 || true,
4242 || {
4243 notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4244 },
4245 )
4246 .unwrap();
4247 assert!(run.output.status.success());
4248 assert!(
4249 !run.prompted,
4250 "a helper that exited in milliseconds was not the one being authorized, \
4251 however many dialogs the machine is showing"
4252 );
4253 assert_eq!(
4254 notices.load(std::sync::atomic::Ordering::Relaxed),
4255 0,
4256 "and it must not tell the user to go answer a dialog it never waited on \
4257 (Parslee-ai/car#878 rides on the same attribution rule as #897)"
4258 );
4259 }
4260
4261 #[cfg(target_os = "macos")]
4265 #[test]
4266 fn a_helper_still_blocked_past_the_threshold_is_attributed_the_dialog() {
4267 let mut command = std::process::Command::new("/bin/sh");
4271 command.args(["-c", "sleep 1"]);
4272 let notices = std::sync::atomic::AtomicUsize::new(0);
4273 let run = bounded_command_output_with(
4274 &mut command,
4275 SECURITY_CLI_TIMEOUT,
4276 || true,
4277 || {
4278 notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4279 },
4280 )
4281 .unwrap();
4282 assert!(run.output.status.success(), "the child must exit naturally");
4283 assert!(
4284 run.prompted,
4285 "a call still running past PROMPT_EVIDENCE_MIN with a dialog up is \
4286 the call that dialog belongs to"
4287 );
4288 assert_eq!(
4297 notices.load(std::sync::atomic::Ordering::Relaxed),
4298 1,
4299 "a blocked read must explain itself exactly once, promptly"
4300 );
4301 }
4302
4303 #[cfg(target_os = "macos")]
4306 #[test]
4307 fn the_prompt_notice_names_the_wait_and_both_remedies() {
4308 let notice = keychain_prompt_notice("car/parslee_access_token");
4309 assert!(
4310 notice.contains("car/parslee_access_token"),
4311 "must name WHICH item is being asked for — the operator who walked away \
4312 and came back to a stack of prompts cannot read the dialog after the \
4313 fact, and the log is the only record (Parslee-ai/car#897): {notice}"
4314 );
4315 assert!(
4316 notice.contains(&SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs().to_string()),
4317 "must state how long CAR will wait, or it reads as an indefinite hang: {notice}"
4318 );
4319 assert!(
4320 notice.contains("Always Allow"),
4321 "must name the one click that also prevents the NEXT prompt: {notice}"
4322 );
4323 assert!(
4324 notice.contains("Keychain Access"),
4325 "must name the remedy for someone who already dismissed the dialog: {notice}"
4326 );
4327 assert!(
4328 notice.contains("not hung"),
4329 "the reported failure was reading the silence as a hang and killing it: {notice}"
4330 );
4331 }
4332
4333 #[cfg(target_os = "macos")]
4336 #[test]
4337 fn describe_item_names_the_keychain_item_from_the_argv() {
4338 assert_eq!(
4339 describe_item(&["find-generic-password", "-s", "car", "-a", "token", "-w"]),
4340 "car/token"
4341 );
4342 assert_eq!(
4343 describe_item(&["delete-generic-password", "-s", "car"]),
4344 "car"
4345 );
4346 assert_eq!(
4347 describe_item(&["find-generic-password", "-a", "token"]),
4348 "token"
4349 );
4350 assert_eq!(describe_item(&["unlock-keychain"]), "unlock-keychain");
4352 assert_eq!(describe_item(&[]), "security");
4353 assert_eq!(
4355 describe_item(&["find-generic-password", "-s"]),
4356 "find-generic-password"
4357 );
4358 }
4359
4360 #[cfg(target_os = "macos")]
4361 #[test]
4362 fn mac_delete_uses_security_cli_and_maps_results() {
4363 let r = SecretRef::new("svc", "key");
4364 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4365
4366 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4367 assert_eq!(
4368 cli.calls(),
4369 vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
4370 );
4371
4372 let cli = FakeSecurityCli::new(vec![security_output(
4373 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4374 b"",
4375 b"The specified item could not be found in the keychain.\n",
4376 )]);
4377 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4378
4379 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4380 let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
4381 assert_access_denied_contains(err, "auth denied");
4382 }
4383}
4384
4385#[cfg(all(test, target_os = "macos"))]
4386mod acl_repair_tests {
4387 use super::tests::*;
4388 use super::*;
4389
4390 fn pw(value: &str) -> Vec<u8> {
4391 format!("password: \"{value}\"\n").into_bytes()
4392 }
4393
4394 fn r(test: &str) -> SecretRef {
4398 SecretRef::new("ai.parslee.car".to_string(), format!("oauth-{test}"))
4399 }
4400
4401 fn verb(call: &[String]) -> &str {
4402 call.first().map(|s| s.as_str()).unwrap_or("")
4403 }
4404
4405 #[test]
4409 fn a_prompted_read_repairs_the_acl() {
4410 let cli = FakeSecurityCli::new(vec![
4411 security_output_prompted(0, Vec::new(), pw("tok")), security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), pw("tok")), ]);
4416 assert_eq!(
4417 mac_get_via_security_cli_with(&r("repairs"), &cli).unwrap(),
4418 "tok"
4419 );
4420 let calls = cli.calls();
4421 let verbs: Vec<&str> = calls.iter().map(|c| verb(c)).collect();
4422 assert_eq!(
4423 verbs,
4424 vec![
4425 "find-generic-password",
4426 "delete-generic-password",
4427 "add-generic-password",
4428 "find-generic-password"
4429 ]
4430 );
4431 let add = cli
4432 .calls()
4433 .into_iter()
4434 .find(|c| verb(c) == "add-generic-password")
4435 .unwrap();
4436 assert!(
4437 add.contains(&"-A".to_string()),
4438 "must write a permissive ACL: {add:?}"
4439 );
4440 }
4441
4442 #[test]
4445 fn a_silent_read_is_left_alone() {
4446 let cli = FakeSecurityCli::new(vec![security_output(0, Vec::new(), pw("tok"))]);
4447 assert_eq!(
4448 mac_get_via_security_cli_with(&r("silent"), &cli).unwrap(),
4449 "tok"
4450 );
4451 assert_eq!(cli.calls().len(), 1, "no repair for an unprompted read");
4452 }
4453
4454 #[test]
4457 fn a_failed_add_puts_the_credential_back() {
4458 let cli = FakeSecurityCli::new(vec![
4459 security_output_prompted(0, Vec::new(), pw("tok")),
4460 security_output(0, Vec::new(), Vec::new()), security_output(1, Vec::new(), b"boom".to_vec()), security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), Vec::new()), ]);
4465 assert_eq!(
4466 mac_get_via_security_cli_with(&r("failed-add"), &cli).unwrap(),
4467 "tok"
4468 );
4469 let adds: Vec<Vec<String>> = cli
4470 .calls()
4471 .into_iter()
4472 .filter(|c| verb(c) == "add-generic-password")
4473 .collect();
4474 assert_eq!(
4475 adds.len(),
4476 2,
4477 "the value must be re-added after a failed add"
4478 );
4479 assert!(
4480 adds[1].contains(&"tok".to_string()),
4481 "and with the ORIGINAL value"
4482 );
4483 }
4484
4485 #[test]
4488 fn a_mismatched_readback_rewrites_the_original_value() {
4489 let cli = FakeSecurityCli::new(vec![
4490 security_output_prompted(0, Vec::new(), pw("tok")),
4491 security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), pw("WRONG")), security_output(0, Vec::new(), Vec::new()), security_output(0, Vec::new(), Vec::new()), ]);
4497 assert_eq!(
4498 mac_get_via_security_cli_with(&r("mismatch"), &cli).unwrap(),
4499 "tok"
4500 );
4501 let adds: Vec<Vec<String>> = cli
4502 .calls()
4503 .into_iter()
4504 .filter(|c| verb(c) == "add-generic-password")
4505 .collect();
4506 assert_eq!(adds.len(), 2, "a mismatch must be corrected");
4507 assert!(adds[1].contains(&"tok".to_string()));
4508 }
4509
4510 #[test]
4513 fn repair_is_attempted_at_most_once_per_process() {
4514 let cli = FakeSecurityCli::new(vec![
4515 security_output_prompted(0, Vec::new(), pw("tok")),
4516 security_output(0, Vec::new(), Vec::new()),
4517 security_output(0, Vec::new(), Vec::new()),
4518 security_output(0, Vec::new(), pw("tok")),
4519 security_output_prompted(0, Vec::new(), pw("tok")), ]);
4521 assert_eq!(
4522 mac_get_via_security_cli_with(&r("once"), &cli).unwrap(),
4523 "tok"
4524 );
4525 let after_first = cli.calls().len();
4526 assert_eq!(
4527 mac_get_via_security_cli_with(&r("once"), &cli).unwrap(),
4528 "tok"
4529 );
4530 assert_eq!(
4531 cli.calls().len(),
4532 after_first + 1,
4533 "the second prompted read must not repair again"
4534 );
4535 }
4536
4537 #[test]
4540 fn an_empty_value_is_never_written_back() {
4541 let cli = FakeSecurityCli::new(vec![security_output_prompted(
4542 0,
4543 Vec::new(),
4544 b"password: \n".to_vec(),
4545 )]);
4546 assert_eq!(
4547 mac_get_via_security_cli_with(&r("empty"), &cli).unwrap(),
4548 ""
4549 );
4550 assert_eq!(cli.calls().len(), 1, "no repair for an empty value");
4551 }
4552
4553 #[test]
4555 fn repair_failure_does_not_fail_the_read() {
4556 let cli = FakeSecurityCli::new(vec![
4557 security_output_prompted(0, Vec::new(), pw("tok")),
4558 security_output(1, Vec::new(), b"nope".to_vec()),
4559 security_output(1, Vec::new(), b"nope".to_vec()),
4560 security_output(1, Vec::new(), b"nope".to_vec()),
4561 security_output(1, Vec::new(), b"nope".to_vec()),
4562 ]);
4563 assert_eq!(
4564 mac_get_via_security_cli_with(&r("failure"), &cli).unwrap(),
4565 "tok",
4566 "the caller's read succeeded; repair is best-effort"
4567 );
4568 }
4569}