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> {
296 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
297 platform_put(self, r, value)
298 }
299
300 pub fn publish(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
317 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
318 platform_publish(self, r, value)
319 }
320
321 pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
323 let s = serde_json::to_string(value)
324 .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
325 self.put(r, &s)
326 }
327
328 pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
336 GET_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
337 platform_get(self, r)
338 }
339
340 pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
342 let raw = self.get(r)?;
343 serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
344 }
345
346 pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
353 DELETE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
354 platform_delete(self, r)
355 }
356
357 pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
362 STATUS_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
363 platform_status(self, r)
364 }
365
366 const PROBE_SERVICE: &'static str = "car-internal";
372 const PROBE_KEY: &'static str = "__availability_probe__";
373 #[cfg(target_os = "macos")]
374 const PROBE_VALUE: &'static str = "car-availability-probe";
375
376 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"))]
430 fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
431 Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
432 }
433}
434
435fn file_backend_dir() -> Option<std::path::PathBuf> {
471 if !cfg!(debug_assertions) {
474 return None;
475 }
476 match std::env::var_os("CAR_SECRETS_FILE_DIR") {
477 Some(d) if !d.is_empty() => {
478 static WARNED: std::sync::Once = std::sync::Once::new();
481 WARNED.call_once(|| {
482 tracing::warn!(
483 "CAR_SECRETS_FILE_DIR set — secrets are PLAINTEXT ON DISK; \
484 test-only, never production"
485 );
486 });
487 Some(std::path::PathBuf::from(d))
488 }
489 _ => None,
490 }
491}
492
493fn file_backend_path(dir: &std::path::Path, r: &SecretRef) -> std::path::PathBuf {
494 let sanitize = |s: &str| s.replace(['/', '\\', '.'], "_");
496 dir.join(format!("{}.{}", sanitize(&r.service), sanitize(&r.key)))
497}
498
499fn file_backend_put(dir: &std::path::Path, r: &SecretRef, value: &str) -> Result<(), SecretError> {
500 std::fs::create_dir_all(dir)
501 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
502 std::fs::write(file_backend_path(dir, r), value)
503 .map_err(|e| SecretError::Backend(format!("file backend write: {e}")))
504}
505
506fn file_backend_publish(
507 dir: &std::path::Path,
508 r: &SecretRef,
509 value: &str,
510) -> Result<(), SecretError> {
511 use std::io::Write;
512
513 std::fs::create_dir_all(dir)
514 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
515 let destination = file_backend_path(dir, r);
516 let nonce = publication_nonce();
517 let staging = destination.with_extension(format!("stage-{nonce}"));
518 let mut options = std::fs::OpenOptions::new();
519 options.create_new(true).write(true);
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::OpenOptionsExt;
523 options.mode(0o600);
524 }
525 let mut file = options
526 .open(&staging)
527 .map_err(|e| SecretError::Backend(format!("file backend stage: {e}")))?;
528 file.write_all(value.as_bytes())
529 .and_then(|_| file.sync_all())
530 .map_err(|e| SecretError::Backend(format!("file backend stage write: {e}")))?;
531 drop(file);
532 if let Err(error) = std::fs::rename(&staging, &destination) {
533 let _ = std::fs::remove_file(&staging);
534 return Err(SecretError::Backend(format!(
535 "file backend publish rename: {error}"
536 )));
537 }
538 Ok(())
539}
540
541fn file_backend_entry_is_merely_absent(dir: &std::path::Path) -> bool {
568 match std::fs::metadata(dir) {
569 Ok(metadata) => metadata.is_dir(),
570 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
571 for ancestor in dir.ancestors().skip(1) {
576 match std::fs::metadata(ancestor) {
577 Ok(metadata) => return metadata.is_dir(),
578 Err(ancestor_error)
579 if ancestor_error.kind() == std::io::ErrorKind::NotFound => {}
580 Err(_) => return false,
581 }
582 }
583 false
584 }
585 Err(_) => false,
586 }
587}
588
589fn file_backend_get(dir: &std::path::Path, r: &SecretRef) -> Result<String, SecretError> {
590 match std::fs::read_to_string(file_backend_path(dir, r)) {
591 Ok(v) => Ok(v),
592 Err(e)
593 if e.kind() == std::io::ErrorKind::NotFound
594 && file_backend_entry_is_merely_absent(dir) =>
595 {
596 Err(SecretError::NotFound {
597 service: r.service.clone(),
598 key: r.key.clone(),
599 })
600 }
601 Err(e) => Err(SecretError::Backend(format!("file backend read: {e}"))),
602 }
603}
604
605fn file_backend_delete(dir: &std::path::Path, r: &SecretRef) -> Result<(), SecretError> {
606 match std::fs::remove_file(file_backend_path(dir, r)) {
607 Ok(()) => Ok(()),
608 Err(e)
609 if e.kind() == std::io::ErrorKind::NotFound
610 && file_backend_entry_is_merely_absent(dir) =>
611 {
612 Ok(())
613 }
614 Err(e) => Err(SecretError::Backend(format!("file backend delete: {e}"))),
615 }
616}
617
618fn file_backend_status(dir: &std::path::Path, r: &SecretRef) -> SecretStatus {
619 SecretStatus {
620 service: r.service.clone(),
621 key: r.key.clone(),
622 exists: file_backend_path(dir, r).exists(),
626 }
627}
628
629#[cfg(target_os = "macos")]
630fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
631 if let Some(dir) = file_backend_dir() {
632 return file_backend_put(&dir, r, value);
633 }
634 mac_put_via_security_cli(&r.service, &r.key, value)
635}
636
637#[cfg(target_os = "macos")]
638fn platform_publish(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
639 if let Some(dir) = file_backend_dir() {
640 return file_backend_publish(&dir, r, value);
641 }
642 mac_publish_via_security_cli(&r.service, &r.key, value)
643}
644
645#[cfg(any(not(target_os = "macos"), test))]
662const CHUNK_SENTINEL: &str = "__car_secrets_chunked_v1__:";
663#[cfg(any(target_os = "windows", test))]
664const CHUNK_SENTINEL_V2: &str = "__car_secrets_chunked_v2__:";
665#[cfg(any(target_os = "windows", test))]
666const CHUNK_SENTINEL_V3: &str = "__car_secrets_chunked_v3__:";
667#[cfg(any(target_os = "windows", test))]
668const CHUNK_VALUE_V3: &str = "__car_secrets_chunk_v3__:";
669#[cfg(any(not(target_os = "macos"), test))]
672const CHUNK_THRESHOLD_UTF16: usize = 2000;
673#[cfg(any(not(target_os = "macos"), test))]
675const CHUNK_CHARS: usize = 1000;
676#[cfg(any(target_os = "windows", test))]
680const WINDOWS_MAX_CHUNKS: usize = 1024;
681#[cfg(any(target_os = "windows", test))]
682const WINDOWS_READ_ATTEMPTS: usize = 4;
683
684#[cfg(not(target_os = "macos"))]
686fn chunk_ref(r: &SecretRef, i: usize) -> SecretRef {
687 SecretRef::new(r.service.clone(), format!("{}#chunk{}", r.key, i))
688}
689
690#[cfg(target_os = "windows")]
691fn chunk_v2_ref(r: &SecretRef, nonce: &str, i: usize) -> SecretRef {
692 SecretRef::new(r.service.clone(), format!("{}#chunkv2#{nonce}#{i}", r.key))
693}
694
695#[cfg(target_os = "windows")]
696fn chunk_v3_ref(r: &SecretRef, generation: ChunkGeneration, i: usize) -> SecretRef {
697 SecretRef::new(
698 r.service.clone(),
699 format!("{}#chunkv3#{}#{i}", r.key, generation.label()),
700 )
701}
702
703#[cfg(target_os = "windows")]
704fn chunk_v3_manifest_ref(r: &SecretRef, generation: ChunkGeneration) -> SecretRef {
705 SecretRef::new(
706 r.service.clone(),
707 format!("{}#chunkv3#{}#manifest", r.key, generation.label()),
708 )
709}
710
711#[cfg(target_os = "windows")]
712fn chunk_v3_retired_v2_ref(r: &SecretRef) -> SecretRef {
713 SecretRef::new(r.service.clone(), format!("{}#chunkv3#retired-v2", r.key))
714}
715
716#[cfg(any(not(target_os = "macos"), test))]
718fn split_on_chars(s: &str, n: usize) -> Vec<String> {
719 let mut out = Vec::new();
720 let mut cur = String::new();
721 let mut count = 0usize;
722 for ch in s.chars() {
723 cur.push(ch);
724 count += 1;
725 if count == n {
726 out.push(std::mem::take(&mut cur));
727 count = 0;
728 }
729 }
730 if !cur.is_empty() {
731 out.push(cur);
732 }
733 out
734}
735
736fn publication_nonce() -> String {
737 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
738 let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
739 let nanos = std::time::SystemTime::now()
740 .duration_since(std::time::UNIX_EPOCH)
741 .map(|duration| duration.as_nanos())
742 .unwrap_or_default();
743 format!("{:x}-{:x}-{:x}", std::process::id(), nanos, sequence)
744}
745
746#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
747#[cfg(any(target_os = "windows", test))]
748enum ChunkGeneration {
749 A,
750 B,
751}
752
753#[cfg(any(target_os = "windows", test))]
754impl ChunkGeneration {
755 fn label(self) -> &'static str {
756 match self {
757 Self::A => "a",
758 Self::B => "b",
759 }
760 }
761
762 fn inactive(self) -> Self {
763 match self {
764 Self::A => Self::B,
765 Self::B => Self::A,
766 }
767 }
768}
769
770#[derive(Debug, Clone, PartialEq, Eq)]
771#[cfg(any(target_os = "windows", test))]
772struct ChunkPublicationPlan {
773 generation: ChunkGeneration,
774 revision: String,
775 chunks: Vec<String>,
776 root: String,
777}
778
779#[cfg(any(target_os = "windows", test))]
780fn chunk_publication_plan(
781 value: &str,
782 generation: ChunkGeneration,
783 revision: &str,
784) -> Result<ChunkPublicationPlan, SecretError> {
785 if revision.is_empty() || revision.contains(':') {
786 return Err(SecretError::Backend(
787 "invalid Windows credential publication revision".to_string(),
788 ));
789 }
790 let mut chunks = split_on_chars(value, CHUNK_CHARS);
791 if chunks.is_empty() {
792 chunks.push(String::new());
793 }
794 if chunks.len() > WINDOWS_MAX_CHUNKS {
795 return Err(SecretError::Backend(format!(
796 "Windows credential publication requires {} chunks; maximum is {WINDOWS_MAX_CHUNKS}",
797 chunks.len()
798 )));
799 }
800 Ok(ChunkPublicationPlan {
801 generation,
802 revision: revision.to_string(),
803 root: format!(
804 "{CHUNK_SENTINEL_V3}{}:{revision}:{}",
805 generation.label(),
806 chunks.len()
807 ),
808 chunks,
809 })
810}
811
812#[cfg(any(target_os = "windows", test))]
813fn encode_v3_chunk(revision: &str, value: &str) -> String {
814 format!("{CHUNK_VALUE_V3}{revision}:{value}")
815}
816
817#[cfg(any(target_os = "windows", test))]
818fn decode_v3_chunk<'a>(raw: &'a str, revision: &str) -> Result<&'a str, SecretError> {
819 let payload = raw.strip_prefix(CHUNK_VALUE_V3).ok_or_else(|| {
820 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
821 })?;
822 let (stored_revision, value) = payload.split_once(':').ok_or_else(|| {
823 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
824 })?;
825 if stored_revision != revision {
826 return Err(SecretError::Backend(
827 "Windows credential chunk revision changed during read".to_string(),
828 ));
829 }
830 Ok(value)
831}
832
833#[cfg(any(target_os = "windows", test))]
834fn parse_v2_sentinel(raw: &str) -> Option<(&str, usize)> {
835 let payload = raw.strip_prefix(CHUNK_SENTINEL_V2)?;
836 let (nonce, count) = payload.rsplit_once(':')?;
837 let count = count.parse::<usize>().ok()?;
838 if nonce.is_empty() || count == 0 || count > WINDOWS_MAX_CHUNKS {
839 return None;
840 }
841 Some((nonce, count))
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
845#[cfg(any(target_os = "windows", test))]
846enum WindowsRootLayout {
847 Inline,
848 LegacyV1 {
849 count: usize,
850 },
851 LegacyV2 {
852 nonce: String,
853 count: usize,
854 },
855 V3 {
856 generation: ChunkGeneration,
857 revision: String,
858 count: usize,
859 },
860}
861
862#[cfg(any(target_os = "windows", test))]
863fn windows_root_layout(raw: &str) -> Result<WindowsRootLayout, SecretError> {
864 if let Some(payload) = raw.strip_prefix(CHUNK_SENTINEL_V3) {
865 let (publication, count) = payload.rsplit_once(':').ok_or_else(|| {
866 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
867 })?;
868 let (generation, revision) = publication.split_once(':').ok_or_else(|| {
869 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
870 })?;
871 let generation = match generation {
872 "a" => ChunkGeneration::A,
873 "b" => ChunkGeneration::B,
874 _ => {
875 return Err(SecretError::Backend(
876 "invalid Windows v3 credential generation".to_string(),
877 ))
878 }
879 };
880 if revision.is_empty() {
881 return Err(SecretError::Backend(
882 "invalid Windows v3 credential publication revision".to_string(),
883 ));
884 }
885 let count = count
886 .parse::<usize>()
887 .ok()
888 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS);
889 return count
890 .map(|count| WindowsRootLayout::V3 {
891 generation,
892 revision: revision.to_string(),
893 count,
894 })
895 .ok_or_else(|| {
896 SecretError::Backend("invalid Windows v3 credential chunk count".to_string())
897 });
898 }
899
900 if raw.starts_with(CHUNK_SENTINEL_V2) {
901 return parse_v2_sentinel(raw)
902 .map(|(nonce, count)| WindowsRootLayout::LegacyV2 {
903 nonce: nonce.to_string(),
904 count,
905 })
906 .ok_or_else(|| {
907 SecretError::Backend("invalid Windows v2 credential root metadata".to_string())
908 });
909 }
910
911 if let Some(count) = raw.strip_prefix(CHUNK_SENTINEL) {
912 return count
913 .parse::<usize>()
914 .ok()
915 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS)
916 .map(|count| WindowsRootLayout::LegacyV1 { count })
917 .ok_or_else(|| {
918 SecretError::Backend("invalid Windows v1 credential chunk count".to_string())
919 });
920 }
921
922 Ok(WindowsRootLayout::Inline)
923}
924
925#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
926#[cfg(any(target_os = "windows", test))]
927enum WindowsCredentialSlot {
928 Root,
929 LegacyV1Chunk(usize),
930 LegacyV2Chunk {
931 nonce: String,
932 index: usize,
933 },
934 V3Chunk {
935 generation: ChunkGeneration,
936 index: usize,
937 },
938 V3Manifest(ChunkGeneration),
939 RetiredV2Manifest,
940}
941
942#[cfg(any(target_os = "windows", test))]
943trait WindowsCredentialBackend {
944 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError>;
945 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError>;
946 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError>;
947}
948
949#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
950#[cfg(any(target_os = "windows", test))]
951struct WindowsCleanupReport {
952 failures: usize,
953}
954
955#[cfg(any(target_os = "windows", test))]
956fn cleanup_windows_slot(
957 backend: &mut impl WindowsCredentialBackend,
958 slot: WindowsCredentialSlot,
959 report: &mut WindowsCleanupReport,
960) {
961 if backend.delete(&slot).is_err() {
962 report.failures += 1;
963 }
964}
965
966#[cfg(any(target_os = "windows", test))]
967fn read_generation_manifest(
968 backend: &mut impl WindowsCredentialBackend,
969 generation: ChunkGeneration,
970) -> Result<usize, SecretError> {
971 let Some(raw) = backend.read(&WindowsCredentialSlot::V3Manifest(generation))? else {
972 return Ok(0);
973 };
974 raw.parse::<usize>()
975 .ok()
976 .filter(|count| *count <= WINDOWS_MAX_CHUNKS)
977 .ok_or_else(|| {
978 SecretError::Backend("invalid Windows credential generation manifest".to_string())
979 })
980}
981
982#[cfg(any(target_os = "windows", test))]
983fn read_retired_v2_manifest(
984 backend: &mut impl WindowsCredentialBackend,
985) -> Result<Option<(String, usize)>, SecretError> {
986 let Some(raw) = backend.read(&WindowsCredentialSlot::RetiredV2Manifest)? else {
987 return Ok(None);
988 };
989 match windows_root_layout(&raw)? {
990 WindowsRootLayout::LegacyV2 { nonce, count } => Ok(Some((nonce, count))),
991 _ => Err(SecretError::Backend(
992 "invalid retired Windows v2 credential manifest".to_string(),
993 )),
994 }
995}
996
997#[cfg(any(target_os = "windows", test))]
998fn cleanup_retired_v2(
999 backend: &mut impl WindowsCredentialBackend,
1000 nonce: &str,
1001 count: usize,
1002 report: &mut WindowsCleanupReport,
1003) {
1004 let failures_before = report.failures;
1005 for index in 0..count {
1006 cleanup_windows_slot(
1007 backend,
1008 WindowsCredentialSlot::LegacyV2Chunk {
1009 nonce: nonce.to_string(),
1010 index,
1011 },
1012 report,
1013 );
1014 }
1015 if report.failures == failures_before {
1018 cleanup_windows_slot(backend, WindowsCredentialSlot::RetiredV2Manifest, report);
1019 }
1020}
1021
1022#[cfg(any(target_os = "windows", test))]
1023fn publish_windows_value(
1024 backend: &mut impl WindowsCredentialBackend,
1025 value: &str,
1026) -> Result<WindowsCleanupReport, SecretError> {
1027 let previous_root = backend.read(&WindowsCredentialSlot::Root)?;
1028 let previous_layout = previous_root
1029 .as_deref()
1030 .map(windows_root_layout)
1031 .transpose()?;
1032 let retired_v2_before = read_retired_v2_manifest(backend)?;
1033 let newly_retired_v2 = match previous_layout.as_ref() {
1034 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1035 let root = previous_root
1036 .as_deref()
1037 .expect("a parsed legacy root came from a present credential");
1038 backend.write(&WindowsCredentialSlot::RetiredV2Manifest, root)?;
1039 Some((nonce.clone(), *count))
1040 }
1041 _ => None,
1042 };
1043 let generation = match previous_layout {
1044 Some(WindowsRootLayout::V3 { generation, .. }) => generation.inactive(),
1045 _ => ChunkGeneration::A,
1046 };
1047 let plan = chunk_publication_plan(value, generation, &publication_nonce())?;
1048
1049 let previous_bound = read_generation_manifest(backend, generation)?;
1053 let high_water = previous_bound.max(plan.chunks.len());
1054 backend.write(
1055 &WindowsCredentialSlot::V3Manifest(generation),
1056 &high_water.to_string(),
1057 )?;
1058
1059 let mut staged = 0;
1060 for (index, chunk) in plan.chunks.iter().enumerate() {
1061 let slot = WindowsCredentialSlot::V3Chunk { generation, index };
1062 if let Err(error) = backend.write(&slot, &encode_v3_chunk(&plan.revision, chunk)) {
1063 let mut ignored_cleanup = WindowsCleanupReport::default();
1064 for staged_index in 0..staged {
1065 cleanup_windows_slot(
1066 backend,
1067 WindowsCredentialSlot::V3Chunk {
1068 generation,
1069 index: staged_index,
1070 },
1071 &mut ignored_cleanup,
1072 );
1073 }
1074 return Err(error);
1075 }
1076 staged += 1;
1077 }
1078
1079 if let Err(error) = backend.write(&WindowsCredentialSlot::Root, &plan.root) {
1082 let mut ignored_cleanup = WindowsCleanupReport::default();
1083 for staged_index in 0..staged {
1084 cleanup_windows_slot(
1085 backend,
1086 WindowsCredentialSlot::V3Chunk {
1087 generation,
1088 index: staged_index,
1089 },
1090 &mut ignored_cleanup,
1091 );
1092 }
1093 return Err(error);
1094 }
1095
1096 let mut cleanup = WindowsCleanupReport::default();
1097 let tail_failures_before = cleanup.failures;
1098 for index in plan.chunks.len()..high_water {
1099 cleanup_windows_slot(
1100 backend,
1101 WindowsCredentialSlot::V3Chunk { generation, index },
1102 &mut cleanup,
1103 );
1104 }
1105 if cleanup.failures == tail_failures_before
1106 && backend
1107 .write(
1108 &WindowsCredentialSlot::V3Manifest(generation),
1109 &plan.chunks.len().to_string(),
1110 )
1111 .is_err()
1112 {
1113 cleanup.failures += 1;
1114 }
1115
1116 if let Some((nonce, count)) = retired_v2_before {
1121 if newly_retired_v2.as_ref() != Some(&(nonce.clone(), count)) {
1122 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1123 }
1124 }
1125
1126 Ok(cleanup)
1127}
1128
1129#[cfg(not(target_os = "macos"))]
1133fn clear_chunks(store: &SecretStore, r: &SecretRef) {
1134 for i in 0..1024 {
1135 let cr = chunk_ref(r, i);
1136 let Ok(entry) = store.entry(&cr) else { break };
1137 match entry.delete_credential() {
1138 Ok(_) => {}
1139 Err(keyring::Error::NoEntry) => break,
1140 Err(_) => break,
1141 }
1142 }
1143}
1144
1145#[cfg(any(target_os = "windows", test))]
1146fn read_windows_value(
1147 backend: &mut impl WindowsCredentialBackend,
1148) -> Result<Option<String>, SecretError> {
1149 for attempt in 0..WINDOWS_READ_ATTEMPTS {
1150 let Some(root) = backend.read(&WindowsCredentialSlot::Root)? else {
1151 return Ok(None);
1152 };
1153 let (slots, expected_revision) = match windows_root_layout(&root)? {
1154 WindowsRootLayout::Inline => return Ok(Some(root)),
1155 WindowsRootLayout::LegacyV1 { count } => (
1156 (0..count)
1157 .map(WindowsCredentialSlot::LegacyV1Chunk)
1158 .collect::<Vec<_>>(),
1159 None,
1160 ),
1161 WindowsRootLayout::LegacyV2 { nonce, count } => (
1162 (0..count)
1163 .map(|index| WindowsCredentialSlot::LegacyV2Chunk {
1164 nonce: nonce.clone(),
1165 index,
1166 })
1167 .collect::<Vec<_>>(),
1168 None,
1169 ),
1170 WindowsRootLayout::V3 {
1171 generation,
1172 revision,
1173 count,
1174 } => (
1175 (0..count)
1176 .map(|index| WindowsCredentialSlot::V3Chunk { generation, index })
1177 .collect::<Vec<_>>(),
1178 Some(revision),
1179 ),
1180 };
1181
1182 let mut value = String::new();
1183 let mut chunk_error = None;
1184 for slot in slots {
1185 match backend.read(&slot) {
1186 Ok(Some(chunk)) => {
1187 if let Some(revision) = expected_revision.as_deref() {
1188 match decode_v3_chunk(&chunk, revision) {
1189 Ok(chunk) => value.push_str(chunk),
1190 Err(error) => {
1191 chunk_error = Some(error);
1192 break;
1193 }
1194 }
1195 } else {
1196 value.push_str(&chunk);
1197 }
1198 }
1199 Ok(None) => {
1200 chunk_error = Some(SecretError::Backend(
1201 "Windows credential publication is incomplete".to_string(),
1202 ));
1203 break;
1204 }
1205 Err(error) => {
1206 chunk_error = Some(error);
1207 break;
1208 }
1209 }
1210 }
1211
1212 let root_after = backend.read(&WindowsCredentialSlot::Root);
1213 if matches!(&root_after, Ok(Some(current)) if current != &root) {
1214 if chunk_error.is_none() {
1215 return Ok(Some(value));
1218 }
1219 if attempt + 1 < WINDOWS_READ_ATTEMPTS {
1220 continue;
1221 }
1222 return Err(SecretError::Backend(
1223 "Windows credential root changed during every read attempt".to_string(),
1224 ));
1225 }
1226 if let Some(error) = chunk_error {
1227 return Err(error);
1228 }
1229 match root_after {
1230 Ok(Some(current)) if current == root => return Ok(Some(value)),
1231 Ok(_) if attempt + 1 < WINDOWS_READ_ATTEMPTS => continue,
1232 Ok(_) => {
1233 return Err(SecretError::Backend(
1234 "Windows credential root changed during every read attempt".to_string(),
1235 ))
1236 }
1237 Err(error) => return Err(error),
1238 }
1239 }
1240 Err(SecretError::Backend(
1241 "Windows credential read retry limit reached".to_string(),
1242 ))
1243}
1244
1245#[cfg(any(target_os = "windows", test))]
1246fn delete_windows_value(
1247 backend: &mut impl WindowsCredentialBackend,
1248) -> Result<WindowsCleanupReport, SecretError> {
1249 let root = backend.read(&WindowsCredentialSlot::Root)?;
1250 let layout = root.as_deref().map(windows_root_layout).transpose()?;
1251 let retired_v2 = read_retired_v2_manifest(backend)?;
1252
1253 let mut generation_bounds = [
1256 (
1257 ChunkGeneration::A,
1258 read_generation_manifest(backend, ChunkGeneration::A)?,
1259 ),
1260 (
1261 ChunkGeneration::B,
1262 read_generation_manifest(backend, ChunkGeneration::B)?,
1263 ),
1264 ];
1265 if let Some(WindowsRootLayout::V3 {
1266 generation, count, ..
1267 }) = layout.as_ref()
1268 {
1269 let (_, bound) = generation_bounds
1270 .iter_mut()
1271 .find(|(candidate, _)| candidate == generation)
1272 .expect("both deterministic generations are present");
1273 *bound = (*bound).max(*count);
1274 }
1275
1276 backend.delete(&WindowsCredentialSlot::Root)?;
1277
1278 let mut cleanup = WindowsCleanupReport::default();
1279 for (generation, bound) in generation_bounds {
1280 let failures_before = cleanup.failures;
1281 for index in 0..bound {
1282 cleanup_windows_slot(
1283 backend,
1284 WindowsCredentialSlot::V3Chunk { generation, index },
1285 &mut cleanup,
1286 );
1287 }
1288 if cleanup.failures == failures_before {
1289 cleanup_windows_slot(
1290 backend,
1291 WindowsCredentialSlot::V3Manifest(generation),
1292 &mut cleanup,
1293 );
1294 }
1295 }
1296 match layout {
1297 Some(WindowsRootLayout::LegacyV1 { count }) => {
1298 for index in 0..count {
1299 cleanup_windows_slot(
1300 backend,
1301 WindowsCredentialSlot::LegacyV1Chunk(index),
1302 &mut cleanup,
1303 );
1304 }
1305 }
1306 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1307 for index in 0..count {
1308 cleanup_windows_slot(
1309 backend,
1310 WindowsCredentialSlot::LegacyV2Chunk {
1311 nonce: nonce.clone(),
1312 index,
1313 },
1314 &mut cleanup,
1315 );
1316 }
1317 }
1318 _ => {}
1319 }
1320 if let Some((nonce, count)) = retired_v2 {
1321 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1322 }
1323 Ok(cleanup)
1324}
1325
1326#[cfg(not(target_os = "macos"))]
1327fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1328 if let Some(dir) = file_backend_dir() {
1329 return file_backend_put(&dir, r, value);
1330 }
1331 if cfg!(windows) {
1334 clear_chunks(store, r);
1337 if value.encode_utf16().count() > CHUNK_THRESHOLD_UTF16 {
1338 let parts = split_on_chars(value, CHUNK_CHARS);
1339 for (i, part) in parts.iter().enumerate() {
1340 let cr = chunk_ref(r, i);
1341 store
1342 .entry(&cr)?
1343 .set_password(part)
1344 .map_err(|e| classify(e, "set_password(chunk)"))?;
1345 }
1346 let sentinel = format!("{CHUNK_SENTINEL}{}", parts.len());
1349 return store
1350 .entry(r)?
1351 .set_password(&sentinel)
1352 .map_err(|e| classify(e, "set_password(sentinel)"));
1353 }
1354 }
1355 let entry = store.entry(r)?;
1356 entry
1357 .set_password(value)
1358 .map_err(|e| classify(e, "set_password"))
1359}
1360
1361#[cfg(target_os = "windows")]
1362struct KeyringWindowsBackend<'a> {
1363 store: &'a SecretStore,
1364 root: &'a SecretRef,
1365}
1366
1367#[cfg(target_os = "windows")]
1368impl KeyringWindowsBackend<'_> {
1369 fn secret_ref(&self, slot: &WindowsCredentialSlot) -> SecretRef {
1370 match slot {
1371 WindowsCredentialSlot::Root => self.root.clone(),
1372 WindowsCredentialSlot::LegacyV1Chunk(index) => chunk_ref(self.root, *index),
1373 WindowsCredentialSlot::LegacyV2Chunk { nonce, index } => {
1374 chunk_v2_ref(self.root, nonce, *index)
1375 }
1376 WindowsCredentialSlot::V3Chunk { generation, index } => {
1377 chunk_v3_ref(self.root, *generation, *index)
1378 }
1379 WindowsCredentialSlot::V3Manifest(generation) => {
1380 chunk_v3_manifest_ref(self.root, *generation)
1381 }
1382 WindowsCredentialSlot::RetiredV2Manifest => chunk_v3_retired_v2_ref(self.root),
1383 }
1384 }
1385}
1386
1387#[cfg(target_os = "windows")]
1388impl WindowsCredentialBackend for KeyringWindowsBackend<'_> {
1389 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
1390 match self.store.entry(&self.secret_ref(slot))?.get_password() {
1391 Ok(value) => Ok(Some(value)),
1392 Err(keyring::Error::NoEntry) => Ok(None),
1393 Err(error) => Err(classify(error, "get_password(windows-publish)")),
1394 }
1395 }
1396
1397 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
1398 self.store
1399 .entry(&self.secret_ref(slot))?
1400 .set_password(value)
1401 .map_err(|error| classify(error, "set_password(windows-publish)"))
1402 }
1403
1404 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
1405 match self
1406 .store
1407 .entry(&self.secret_ref(slot))?
1408 .delete_credential()
1409 {
1410 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1411 Err(error) => Err(classify(error, "delete_credential(windows-publish)")),
1412 }
1413 }
1414}
1415
1416#[cfg(target_os = "windows")]
1417fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1418 if let Some(dir) = file_backend_dir() {
1419 return file_backend_publish(&dir, r, value);
1420 }
1421 let mut backend = KeyringWindowsBackend { store, root: r };
1422 let cleanup = publish_windows_value(&mut backend, value)?;
1423 if cleanup.failures > 0 {
1424 tracing::warn!(
1425 cleanup_failures = cleanup.failures,
1426 "Windows credential publication committed; bounded cleanup deferred"
1427 );
1428 }
1429 Ok(())
1430}
1431
1432#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1433fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1434 if let Some(dir) = file_backend_dir() {
1435 return file_backend_publish(&dir, r, value);
1436 }
1437 store
1438 .entry(r)?
1439 .set_password(value)
1440 .map_err(|error| classify(error, "publish_password"))
1441}
1442
1443#[cfg(target_os = "macos")]
1444fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1445 if let Some(dir) = file_backend_dir() {
1446 return file_backend_get(&dir, r);
1447 }
1448 mac_get_via_security_cli(r)
1449}
1450
1451#[cfg(target_os = "windows")]
1452fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1453 if let Some(dir) = file_backend_dir() {
1454 return file_backend_get(&dir, r);
1455 }
1456 let mut backend = KeyringWindowsBackend { store, root: r };
1457 match read_windows_value(&mut backend)? {
1458 Some(value) => Ok(value),
1459 None => Err(SecretError::NotFound {
1460 service: r.service.clone(),
1461 key: r.key.clone(),
1462 }),
1463 }
1464}
1465
1466#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1467fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1468 if let Some(dir) = file_backend_dir() {
1469 return file_backend_get(&dir, r);
1470 }
1471 match store.entry(r)?.get_password() {
1472 Ok(value) => Ok(value),
1473 Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
1474 service: r.service.clone(),
1475 key: r.key.clone(),
1476 }),
1477 Err(error) => Err(classify(error, "get_password")),
1478 }
1479}
1480
1481#[cfg(target_os = "macos")]
1482fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1483 if let Some(dir) = file_backend_dir() {
1484 return file_backend_delete(&dir, r);
1485 }
1486 mac_delete_via_security_cli(r)
1487}
1488
1489#[cfg(target_os = "windows")]
1490fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1491 if let Some(dir) = file_backend_dir() {
1492 return file_backend_delete(&dir, r);
1493 }
1494 let mut backend = KeyringWindowsBackend { store, root: r };
1495 let cleanup = delete_windows_value(&mut backend)?;
1496 if cleanup.failures > 0 {
1497 tracing::warn!(
1498 cleanup_failures = cleanup.failures,
1499 "Windows credential root deleted; bounded cleanup deferred"
1500 );
1501 }
1502 Ok(())
1503}
1504
1505#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1506fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1507 if let Some(dir) = file_backend_dir() {
1508 return file_backend_delete(&dir, r);
1509 }
1510 match store.entry(r)?.delete_credential() {
1511 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
1512 Err(error) => Err(classify(error, "delete_credential")),
1513 }
1514}
1515
1516#[cfg(target_os = "macos")]
1517fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1518 if let Some(dir) = file_backend_dir() {
1519 return Ok(file_backend_status(&dir, r));
1520 }
1521 mac_status_via_security_cli(r)
1522}
1523
1524#[cfg(not(target_os = "macos"))]
1525fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1526 if let Some(dir) = file_backend_dir() {
1527 return Ok(file_backend_status(&dir, r));
1528 }
1529 let entry = store.entry(r)?;
1530 let exists = match entry.get_password() {
1531 Ok(_) => true,
1532 Err(keyring::Error::NoEntry) => false,
1533 Err(other) => return Err(classify(other, "status")),
1534 };
1535 Ok(SecretStatus {
1536 service: r.service.clone(),
1537 key: r.key.clone(),
1538 exists,
1539 })
1540}
1541
1542#[cfg(target_os = "macos")]
1561fn platform_availability(_store: &SecretStore) -> AvailabilityCheck {
1562 mac_availability_via_security_cli_with(&SystemSecurityCli)
1563}
1564
1565#[cfg(target_os = "macos")]
1566fn mac_availability_via_security_cli_with(cli: &impl SecurityCli) -> AvailabilityCheck {
1567 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1568 let result = mac_exists_via_security_cli_with(&probe, cli).and_then(|_| {
1569 mac_put_via_security_cli_with(&probe.service, &probe.key, SecretStore::PROBE_VALUE, cli)
1570 .and_then(|()| mac_delete_via_security_cli_with(&probe, cli))
1571 });
1572
1573 match result {
1574 Ok(()) => AvailabilityCheck {
1575 available: true,
1576 reason: None,
1577 },
1578 Err(error) => AvailabilityCheck {
1579 available: false,
1580 reason: Some(error.to_string()),
1581 },
1582 }
1583}
1584
1585#[cfg(not(target_os = "macos"))]
1586fn platform_availability(store: &SecretStore) -> AvailabilityCheck {
1587 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1588 match store.entry(&probe) {
1589 Ok(entry) => match entry.get_password() {
1590 Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
1591 available: true,
1592 reason: None,
1593 },
1594 Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
1595 available: false,
1596 reason: Some(format!("platform failure: {e}")),
1597 },
1598 Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
1599 available: false,
1600 reason: Some(format!("no storage access: {e}")),
1601 },
1602 Err(_) => AvailabilityCheck {
1609 available: true,
1610 reason: None,
1611 },
1612 },
1613 Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
1614 available: false,
1615 reason: Some(reason),
1616 },
1617 Err(other) => AvailabilityCheck {
1618 available: false,
1619 reason: Some(other.to_string()),
1620 },
1621 }
1622}
1623
1624#[cfg(target_os = "macos")]
1635fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
1636 mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
1637}
1638
1639#[cfg(target_os = "macos")]
1640fn mac_publish_via_security_cli(
1641 service: &str,
1642 account: &str,
1643 value: &str,
1644) -> Result<(), SecretError> {
1645 mac_publish_via_security_cli_with(service, account, value, &SystemSecurityCli)
1646}
1647
1648#[cfg(target_os = "macos")]
1649fn mac_publish_via_security_cli_with(
1650 service: &str,
1651 account: &str,
1652 value: &str,
1653 cli: &impl SecurityCli,
1654) -> Result<(), SecretError> {
1655 mac_write_via_security_cli(service, account, value, cli)
1656}
1657
1658#[cfg(target_os = "macos")]
1659fn mac_put_via_security_cli_with(
1660 service: &str,
1661 account: &str,
1662 value: &str,
1663 cli: &impl SecurityCli,
1664) -> Result<(), SecretError> {
1665 mac_write_via_security_cli(service, account, value, cli)
1666}
1667
1668#[cfg(target_os = "macos")]
1676fn mac_write_via_security_cli(
1677 service: &str,
1678 account: &str,
1679 value: &str,
1680 cli: &impl SecurityCli,
1681) -> Result<(), SecretError> {
1682 let output = cli
1683 .output(&[
1684 "add-generic-password",
1685 "-U", "-A", "-s",
1688 service,
1689 "-a",
1690 account,
1691 "-w",
1692 value,
1693 ])
1694 .map_err(|e| security_cli_spawn_error("add-generic-password", e))?;
1695 if output.success {
1696 return Ok(());
1697 }
1698 Err(security_cli_backend_error("add-generic-password", output))
1699}
1700
1701#[cfg(target_os = "macos")]
1702const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
1703
1704#[cfg(target_os = "macos")]
1705#[derive(Debug)]
1706struct SecurityCliOutput {
1707 success: bool,
1708 code: Option<i32>,
1709 stdout: Vec<u8>,
1710 stderr: Vec<u8>,
1711 prompted: bool,
1722 timed_out: bool,
1724}
1725
1726#[cfg(target_os = "macos")]
1727trait SecurityCli {
1728 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput>;
1729}
1730
1731#[cfg(target_os = "macos")]
1732struct SystemSecurityCli;
1733
1734#[cfg(target_os = "macos")]
1735impl SecurityCli for SystemSecurityCli {
1736 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
1737 let mut command = std::process::Command::new("/usr/bin/security");
1738 command.args(args);
1739 if let Some(keychain_path) = selected_keychain_path()? {
1740 command.arg(keychain_path);
1741 }
1742 let run = bounded_command_output(&mut command, SECURITY_CLI_TIMEOUT, &describe_item(args))?;
1743 Ok(SecurityCliOutput {
1744 success: run.output.status.success(),
1745 code: run.output.status.code(),
1746 stdout: run.output.stdout,
1747 stderr: run.output.stderr,
1748 prompted: run.prompted,
1749 timed_out: run.timed_out,
1750 })
1751 }
1752}
1753
1754#[cfg(target_os = "macos")]
1755const KEYCHAIN_PATH_ENV: &str = "CAR_KEYCHAIN_PATH";
1756
1757#[cfg(target_os = "macos")]
1758const KEYCHAIN_PROOF_ROOT_ENV: &str = "CAR_KEYCHAIN_PROOF_ROOT";
1759
1760#[cfg(target_os = "macos")]
1763fn selected_keychain_path() -> std::io::Result<Option<std::path::PathBuf>> {
1764 let Some(path) = std::env::var_os(KEYCHAIN_PATH_ENV).filter(|value| !value.is_empty()) else {
1765 return Ok(None);
1766 };
1767 let proof_root = std::env::var_os(KEYCHAIN_PROOF_ROOT_ENV)
1768 .filter(|value| !value.is_empty())
1769 .ok_or_else(|| {
1770 std::io::Error::new(
1771 std::io::ErrorKind::InvalidInput,
1772 format!("{KEYCHAIN_PATH_ENV} requires {KEYCHAIN_PROOF_ROOT_ENV}"),
1773 )
1774 })?;
1775 validate_keychain_path(
1776 std::path::Path::new(&path),
1777 std::path::Path::new(&proof_root),
1778 )
1779 .map(Some)
1780}
1781
1782#[cfg(target_os = "macos")]
1783fn validate_keychain_path(
1784 path: &std::path::Path,
1785 proof_root: &std::path::Path,
1786) -> std::io::Result<std::path::PathBuf> {
1787 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1788
1789 if !path.is_absolute() || !proof_root.is_absolute() {
1790 return Err(std::io::Error::new(
1791 std::io::ErrorKind::InvalidInput,
1792 "isolated Keychain path and proof root must be absolute",
1793 ));
1794 }
1795
1796 let expected_uid = current_effective_uid();
1797 let root_metadata = std::fs::symlink_metadata(proof_root)?;
1798 if root_metadata.file_type().is_symlink()
1799 || !root_metadata.is_dir()
1800 || root_metadata.uid() != expected_uid
1801 || root_metadata.permissions().mode() & 0o077 != 0
1802 {
1803 return Err(std::io::Error::new(
1804 std::io::ErrorKind::PermissionDenied,
1805 "Keychain proof root must be an owner-private, non-symlink directory owned by the current user",
1806 ));
1807 }
1808
1809 let path_metadata = std::fs::symlink_metadata(path)?;
1810 if path_metadata.file_type().is_symlink()
1811 || !path_metadata.is_file()
1812 || path_metadata.uid() != expected_uid
1813 || path_metadata.permissions().mode() & 0o077 != 0
1814 {
1815 return Err(std::io::Error::new(
1816 std::io::ErrorKind::PermissionDenied,
1817 "isolated Keychain must be an owner-private, non-symlink regular file owned by the current user",
1818 ));
1819 }
1820
1821 let canonical_root = std::fs::canonicalize(proof_root)?;
1822 let canonical_path = std::fs::canonicalize(path)?;
1823 if !canonical_path.starts_with(&canonical_root) || canonical_path == canonical_root {
1824 return Err(std::io::Error::new(
1825 std::io::ErrorKind::PermissionDenied,
1826 "isolated Keychain must be canonically contained by its proof root",
1827 ));
1828 }
1829 Ok(canonical_path)
1830}
1831
1832#[cfg(target_os = "macos")]
1833fn current_effective_uid() -> u32 {
1834 unsafe extern "C" {
1835 fn geteuid() -> u32;
1836 }
1837 unsafe { geteuid() }
1839}
1840
1841#[cfg(target_os = "macos")]
1842const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
1843
1844#[cfg(target_os = "macos")]
1849const SECURITY_CLI_INTERACTIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
1850
1851#[cfg(target_os = "macos")]
1862fn security_agent_is_prompting() -> bool {
1863 std::process::Command::new("/usr/bin/pgrep")
1864 .arg("-x")
1865 .arg("SecurityAgent")
1866 .stdout(std::process::Stdio::null())
1867 .stderr(std::process::Stdio::null())
1868 .status()
1869 .map(|s| s.success())
1870 .unwrap_or(false)
1871}
1872
1873#[cfg(target_os = "macos")]
1901const PROMPT_EVIDENCE_MIN: std::time::Duration = std::time::Duration::from_millis(500);
1902
1903#[cfg(target_os = "macos")]
1909fn dialog_is_evidence_for_this_read(dialog_on_screen: bool, elapsed: std::time::Duration) -> bool {
1910 dialog_on_screen && elapsed >= PROMPT_EVIDENCE_MIN
1911}
1912
1913#[cfg(target_os = "macos")]
1915#[derive(Debug)]
1916struct BoundedRun {
1917 output: std::process::Output,
1918 prompted: bool,
1919 timed_out: bool,
1920}
1921
1922#[cfg(target_os = "macos")]
1937fn describe_item(args: &[&str]) -> String {
1938 let flag = |name: &str| {
1939 args.iter()
1940 .position(|a| *a == name)
1941 .and_then(|i| args.get(i + 1))
1942 .copied()
1943 };
1944 match (flag("-s"), flag("-a")) {
1945 (Some(service), Some(account)) => format!("{service}/{account}"),
1946 (Some(service), None) => service.to_string(),
1947 (None, Some(account)) => account.to_string(),
1948 (None, None) => args.first().copied().unwrap_or("security").to_string(),
1951 }
1952}
1953
1954#[cfg(target_os = "macos")]
1961fn keychain_prompt_notice(item: &str) -> String {
1962 format!(
1963 "waiting on a macOS keychain prompt for \"{item}\" (up to {}s) — CAR is not \
1964 hung. Click \"Always Allow\" on the dialog (it may be behind another \
1965 window), or grant the \"car\" service access in Keychain Access.",
1966 SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs()
1967 )
1968}
1969
1970#[cfg(target_os = "macos")]
1971fn bounded_command_output(
1972 command: &mut std::process::Command,
1973 timeout: std::time::Duration,
1974 item: &str,
1975) -> std::io::Result<BoundedRun> {
1976 bounded_command_output_with(command, timeout, security_agent_is_prompting, || {
1977 tracing::warn!("{}", keychain_prompt_notice(item));
1984 })
1985}
1986
1987#[cfg(target_os = "macos")]
1999fn bounded_command_output_with(
2000 command: &mut std::process::Command,
2001 timeout: std::time::Duration,
2002 dialog_probe: impl Fn() -> bool,
2003 on_waiting_for_user: impl Fn(),
2004) -> std::io::Result<BoundedRun> {
2005 use std::io::Read;
2006 use std::process::Stdio;
2007 use std::time::Instant;
2008
2009 command.stdout(Stdio::piped()).stderr(Stdio::piped());
2010 let mut child = command.spawn()?;
2011 let stdout = child
2012 .stdout
2013 .take()
2014 .ok_or_else(|| std::io::Error::other("keychain helper stdout was not piped"))?;
2015 let stderr = child
2016 .stderr
2017 .take()
2018 .ok_or_else(|| std::io::Error::other("keychain helper stderr was not piped"))?;
2019 let stdout_reader = std::thread::spawn(move || {
2020 let mut bytes = Vec::new();
2021 let mut stdout = stdout;
2022 stdout.read_to_end(&mut bytes)?;
2023 Ok::<_, std::io::Error>(bytes)
2024 });
2025 let stderr_reader = std::thread::spawn(move || {
2026 let mut bytes = Vec::new();
2027 let mut stderr = stderr;
2028 stderr.read_to_end(&mut bytes)?;
2029 Ok::<_, std::io::Error>(bytes)
2030 });
2031 let started = Instant::now();
2032 let mut prompted = false;
2035 let (status, timed_out) = loop {
2036 if let Some(status) = child.try_wait()? {
2037 break (status, false);
2038 }
2039 let dialog_on_screen = dialog_probe();
2052 if !prompted && dialog_is_evidence_for_this_read(dialog_on_screen, started.elapsed()) {
2060 prompted = true;
2061 on_waiting_for_user();
2079 }
2080 let deadline = if dialog_on_screen {
2081 SECURITY_CLI_INTERACTIVE_TIMEOUT
2082 } else {
2083 timeout
2084 };
2085 if started.elapsed() >= deadline {
2086 let _ = child.kill();
2087 break (child.wait()?, true);
2088 }
2089 std::thread::sleep(std::time::Duration::from_millis(10));
2090 };
2091 let join_reader = |reader: std::thread::JoinHandle<std::io::Result<Vec<u8>>>,
2092 stream: &str|
2093 -> std::io::Result<Vec<u8>> {
2094 reader.join().map_err(|_| {
2095 std::io::Error::other(format!("keychain helper {stream} reader panicked"))
2096 })?
2097 };
2098 let stdout = join_reader(stdout_reader, "stdout")?;
2099 let mut stderr = join_reader(stderr_reader, "stderr")?;
2100 if timed_out {
2101 stderr.extend_from_slice(
2108 format!(
2109 "\nCAR killed the keychain helper after {}ms. This usually means a macOS \
2110 keychain prompt is open and waiting: click \"Always Allow\" (or grant access \
2111 to the \"car\" service in Keychain Access). Until it is answered, CAR cannot \
2112 read your saved credentials and will report that no account is signed in.",
2113 timeout.as_millis()
2114 )
2115 .as_bytes(),
2116 );
2117 }
2118 Ok(BoundedRun {
2119 output: std::process::Output {
2120 status,
2121 stdout,
2122 stderr,
2123 },
2124 prompted,
2125 timed_out,
2126 })
2127}
2128
2129#[cfg(target_os = "macos")]
2137fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
2138 mac_get_via_security_cli_with(r, &SystemSecurityCli)
2139}
2140
2141#[cfg(target_os = "macos")]
2150fn mac_get_via_security_cli_with(
2151 r: &SecretRef,
2152 cli: &impl SecurityCli,
2153) -> Result<String, SecretError> {
2154 let output = cli
2155 .output(&[
2156 "find-generic-password",
2157 "-s",
2158 &r.service,
2159 "-a",
2160 &r.key,
2161 "-g",
2162 ])
2163 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2164 if !output.success {
2165 return security_cli_not_found_or_backend("find-generic-password", r, output);
2166 }
2167 if output.prompted {
2168 tracing::debug!(
2169 service = %r.service,
2170 key = %r.key,
2171 "keychain read completed after user approval; preserving the item and its persisted grant"
2172 );
2173 }
2174 mac_parse_security_cli_password(&output)
2175}
2176
2177#[cfg(target_os = "macos")]
2178fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
2179 let line = mac_security_cli_text(&output.stderr, "stderr")?
2180 .lines()
2181 .find(|line| line.starts_with("password:"))
2182 .or_else(|| {
2183 mac_security_cli_text(&output.stdout, "stdout")
2184 .ok()
2185 .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
2186 })
2187 .ok_or_else(|| {
2188 SecretError::Backend(
2189 "/usr/bin/security find-generic-password -g did not print a password line"
2190 .to_string(),
2191 )
2192 })?;
2193
2194 let payload = line
2195 .strip_prefix("password:")
2196 .expect("password line prefix was checked")
2197 .trim_start();
2198
2199 if payload.is_empty() {
2200 return Ok(String::new());
2201 }
2202
2203 let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
2204 mac_decode_security_cli_hex_password(hex_and_preview)?
2205 } else {
2206 mac_decode_security_cli_quoted_password(payload)?
2207 };
2208
2209 String::from_utf8(bytes).map_err(|e| {
2210 SecretError::Backend(format!(
2211 "/usr/bin/security find-generic-password password was not valid utf-8: {}",
2212 e
2213 ))
2214 })
2215}
2216
2217#[cfg(target_os = "macos")]
2218fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
2219 std::str::from_utf8(bytes).map_err(|e| {
2220 SecretError::Backend(format!(
2221 "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
2222 ))
2223 })
2224}
2225
2226#[cfg(target_os = "macos")]
2227fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
2228 let hex: String = hex_and_preview
2229 .chars()
2230 .take_while(|c| c.is_ascii_hexdigit())
2231 .collect();
2232 if hex.is_empty() || !hex.len().is_multiple_of(2) {
2233 return Err(SecretError::Backend(format!(
2234 "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
2235 )));
2236 }
2237
2238 (0..hex.len())
2239 .step_by(2)
2240 .map(|i| {
2241 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
2242 SecretError::Backend(format!(
2243 "/usr/bin/security find-generic-password printed invalid password hex: {e}"
2244 ))
2245 })
2246 })
2247 .collect()
2248}
2249
2250#[cfg(target_os = "macos")]
2251fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
2252 let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
2253 match quoted {
2254 Some(value) => Ok(value.as_bytes().to_vec()),
2255 None => Err(SecretError::Backend(
2256 "/usr/bin/security find-generic-password printed an unrecognized password line"
2257 .to_string(),
2258 )),
2259 }
2260}
2261
2262#[cfg(target_os = "macos")]
2263fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
2264 mac_status_via_security_cli_with(r, &SystemSecurityCli)
2265}
2266
2267#[cfg(target_os = "macos")]
2268fn mac_status_via_security_cli_with(
2269 r: &SecretRef,
2270 cli: &impl SecurityCli,
2271) -> Result<SecretStatus, SecretError> {
2272 let exists = mac_exists_via_security_cli_with(r, cli)?;
2273 Ok(SecretStatus {
2274 service: r.service.clone(),
2275 key: r.key.clone(),
2276 exists,
2277 })
2278}
2279
2280#[cfg(target_os = "macos")]
2285fn mac_exists_via_security_cli_with(
2286 r: &SecretRef,
2287 cli: &impl SecurityCli,
2288) -> Result<bool, SecretError> {
2289 let output = cli
2290 .output(&["find-generic-password", "-s", &r.service, "-a", &r.key])
2291 .map_err(|e| security_cli_spawn_error("find-generic-password", e))?;
2292 if output.success {
2293 return Ok(true);
2294 }
2295 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2296 return Ok(false);
2297 }
2298 Err(security_cli_backend_error("find-generic-password", output))
2299}
2300
2301#[cfg(target_os = "macos")]
2302fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
2303 mac_delete_via_security_cli_with(r, &SystemSecurityCli)
2304}
2305
2306#[cfg(target_os = "macos")]
2309fn mac_delete_via_security_cli_with(
2310 r: &SecretRef,
2311 cli: &impl SecurityCli,
2312) -> Result<(), SecretError> {
2313 let output = cli
2314 .output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])
2315 .map_err(|e| security_cli_spawn_error("delete-generic-password", e))?;
2316 if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2317 return Ok(());
2318 }
2319 Err(security_cli_backend_error(
2320 "delete-generic-password",
2321 output,
2322 ))
2323}
2324
2325#[cfg(target_os = "macos")]
2326fn security_cli_not_found_or_backend<T>(
2327 command: &str,
2328 r: &SecretRef,
2329 output: SecurityCliOutput,
2330) -> Result<T, SecretError> {
2331 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2332 return Err(SecretError::NotFound {
2333 service: r.service.clone(),
2334 key: r.key.clone(),
2335 });
2336 }
2337 Err(security_cli_backend_error(command, output))
2338}
2339
2340#[cfg(target_os = "macos")]
2341fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
2342 SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
2343}
2344
2345#[cfg(target_os = "macos")]
2346fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
2347 let stderr = String::from_utf8_lossy(&output.stderr);
2348 if output.timed_out {
2349 return classify_helper_timeout(command);
2350 }
2351 let code = output.code.unwrap_or(-1);
2352 match classify_security_error(code, stderr.trim()) {
2353 SecretError::Backend(_) => SecretError::Backend(format!(
2354 "/usr/bin/security {command} failed: code={code} {}",
2355 stderr.trim()
2356 )),
2357 typed => typed,
2358 }
2359}
2360
2361#[cfg(target_os = "macos")]
2362fn classify_security_error(code: i32, detail: &str) -> SecretError {
2363 let normalized = detail.to_ascii_lowercase();
2364 if code == -128 || (code == 128 && normalized.contains("cancel")) {
2365 return SecretError::UserCancelled {
2366 message: detail.to_string(),
2367 };
2368 }
2369 if code == -25293
2370 || code == 51
2371 || normalized.contains("authorization denied")
2372 || normalized.contains("auth denied")
2373 || normalized.contains("interaction is not allowed")
2374 {
2375 return SecretError::AccessDenied {
2376 message: detail.to_string(),
2377 };
2378 }
2379 SecretError::Backend(format!("macOS security error: code={code} {detail}"))
2380}
2381
2382#[cfg(target_os = "macos")]
2383fn classify_helper_timeout(operation: &str) -> SecretError {
2384 SecretError::HelperTimedOut {
2385 operation: operation.to_string(),
2386 }
2387}
2388
2389#[cfg(not(target_os = "macos"))]
2395fn classify(e: keyring::Error, op: &str) -> SecretError {
2396 use keyring::Error as K;
2397 match e {
2398 K::NoEntry => SecretError::NotFound {
2399 service: String::new(),
2400 key: String::new(),
2401 },
2402 K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2403 K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2404 K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
2405 other => SecretError::Backend(format!("{}: {}", op, other)),
2406 }
2407}
2408
2409#[cfg(test)]
2410mod chunk_tests {
2411 use super::*;
2412 use std::collections::BTreeMap;
2413
2414 #[test]
2415 fn split_on_chars_covers_boundaries() {
2416 assert_eq!(split_on_chars("", 3), Vec::<String>::new());
2417 assert_eq!(split_on_chars("abc", 3), vec!["abc"]);
2418 assert_eq!(split_on_chars("abcd", 3), vec!["abc", "d"]);
2419 assert_eq!(split_on_chars("abcdef", 2), vec!["ab", "cd", "ef"]);
2420 let big: String = "x".repeat(4000);
2422 let joined: String = split_on_chars(&big, CHUNK_CHARS).concat();
2423 assert_eq!(joined, big);
2424 }
2425
2426 #[test]
2427 fn sentinel_round_trips_the_chunk_count() {
2428 let n = split_on_chars(&"y".repeat(3300), CHUNK_CHARS).len();
2429 let sentinel = format!("{CHUNK_SENTINEL}{n}");
2430 let parsed = sentinel
2431 .strip_prefix(CHUNK_SENTINEL)
2432 .and_then(|s| s.parse::<usize>().ok());
2433 assert_eq!(parsed, Some(4)); assert!("eyJhbGciOi.reallongjwt"
2436 .strip_prefix(CHUNK_SENTINEL)
2437 .is_none());
2438 }
2439
2440 #[test]
2441 fn threshold_leaves_small_values_inline() {
2442 assert!("short-api-key".encode_utf16().count() <= CHUNK_THRESHOLD_UTF16);
2445 assert!("z".repeat(2001).encode_utf16().count() > CHUNK_THRESHOLD_UTF16);
2446 }
2447
2448 #[derive(Debug, Clone)]
2449 struct FailureRule {
2450 slot: WindowsCredentialSlot,
2451 matches_to_skip: usize,
2452 }
2453
2454 #[derive(Debug, Clone, Default)]
2455 struct MemoryWindowsBackend {
2456 entries: BTreeMap<WindowsCredentialSlot, String>,
2457 mutation_calls: usize,
2458 crash_after_mutation: Option<usize>,
2459 fail_write: Option<FailureRule>,
2460 fail_delete: Option<FailureRule>,
2461 }
2462
2463 impl MemoryWindowsBackend {
2464 fn after_mutation(&mut self) {
2465 self.mutation_calls += 1;
2466 if self.crash_after_mutation == Some(self.mutation_calls) {
2467 panic!("injected Windows credential process crash");
2468 }
2469 }
2470
2471 fn should_fail(rule: &mut Option<FailureRule>, slot: &WindowsCredentialSlot) -> bool {
2472 let Some(candidate) = rule.as_mut() else {
2473 return false;
2474 };
2475 if &candidate.slot != slot {
2476 return false;
2477 }
2478 if candidate.matches_to_skip > 0 {
2479 candidate.matches_to_skip -= 1;
2480 return false;
2481 }
2482 *rule = None;
2483 true
2484 }
2485
2486 fn reset_faults(&mut self) {
2487 self.mutation_calls = 0;
2488 self.crash_after_mutation = None;
2489 self.fail_write = None;
2490 self.fail_delete = None;
2491 }
2492
2493 fn root(&self) -> String {
2494 self.entries
2495 .get(&WindowsCredentialSlot::Root)
2496 .expect("root credential")
2497 .clone()
2498 }
2499 }
2500
2501 impl WindowsCredentialBackend for MemoryWindowsBackend {
2502 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2503 Ok(self.entries.get(slot).cloned())
2504 }
2505
2506 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2507 if Self::should_fail(&mut self.fail_write, slot) {
2508 return Err(SecretError::Backend(
2509 "injected Windows credential write failure".to_string(),
2510 ));
2511 }
2512 self.entries.insert(slot.clone(), value.to_string());
2513 self.after_mutation();
2514 Ok(())
2515 }
2516
2517 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2518 if Self::should_fail(&mut self.fail_delete, slot) {
2519 return Err(SecretError::Backend(
2520 "injected Windows credential cleanup failure".to_string(),
2521 ));
2522 }
2523 self.entries.remove(slot);
2524 self.after_mutation();
2525 Ok(())
2526 }
2527 }
2528
2529 fn publish(backend: &mut MemoryWindowsBackend, value: &str) -> WindowsCleanupReport {
2530 publish_windows_value(backend, value).expect("publication")
2531 }
2532
2533 fn read(backend: &mut impl WindowsCredentialBackend) -> String {
2534 read_windows_value(backend)
2535 .expect("read succeeds")
2536 .expect("root exists")
2537 }
2538
2539 fn legacy_v2(value: &str, nonce: &str) -> MemoryWindowsBackend {
2540 let mut backend = MemoryWindowsBackend::default();
2541 let chunks = split_on_chars(value, CHUNK_CHARS);
2542 backend.entries.insert(
2543 WindowsCredentialSlot::Root,
2544 format!("{CHUNK_SENTINEL_V2}{nonce}:{}", chunks.len()),
2545 );
2546 for (index, chunk) in chunks.into_iter().enumerate() {
2547 backend.entries.insert(
2548 WindowsCredentialSlot::LegacyV2Chunk {
2549 nonce: nonce.to_string(),
2550 index,
2551 },
2552 chunk,
2553 );
2554 }
2555 backend
2556 }
2557
2558 fn assert_backend_error(error: SecretError, needle: &str) {
2559 match error {
2560 SecretError::Backend(message) => assert!(message.contains(needle), "{message}"),
2561 other => panic!("expected backend error, got {other:?}"),
2562 }
2563 }
2564
2565 #[test]
2566 fn v3_publication_uses_revisioned_dual_generation_roots() {
2567 let value = "v".repeat(3300);
2568 let plan = chunk_publication_plan(&value, ChunkGeneration::B, "revision-7").unwrap();
2569
2570 assert_eq!(plan.generation, ChunkGeneration::B);
2571 assert_eq!(plan.chunks.concat(), value);
2572 assert_eq!(
2573 windows_root_layout(&plan.root).unwrap(),
2574 WindowsRootLayout::V3 {
2575 generation: ChunkGeneration::B,
2576 revision: "revision-7".to_string(),
2577 count: 4,
2578 }
2579 );
2580 assert!(
2581 plan.chunks
2582 .iter()
2583 .all(|chunk| chunk.encode_utf16().count() <= CHUNK_CHARS),
2584 "every staged credential must remain below the platform cap"
2585 );
2586 }
2587
2588 #[test]
2589 fn reader_capturing_old_root_finishes_after_writer_swaps_root() {
2590 let old = "old-".repeat(900);
2591 let new = "new-".repeat(900);
2592 let mut backend = MemoryWindowsBackend::default();
2593 publish(&mut backend, &old);
2594
2595 let mut reader = InterleavingReader::new(backend, vec![new.as_str()]);
2596 assert_eq!(read(&mut reader), old);
2597 assert_eq!(read(&mut reader.inner), new);
2598 }
2599
2600 #[test]
2601 fn reader_detects_generation_aba_and_retries_latest_root() {
2602 let old = "old-".repeat(900);
2603 let middle = "mid-".repeat(1100);
2604 let latest = "latest-".repeat(700);
2605 let mut backend = MemoryWindowsBackend::default();
2606 publish(&mut backend, &old);
2607
2608 let mut reader = InterleavingReader::new(backend, vec![middle.as_str(), latest.as_str()]);
2609 assert_eq!(read(&mut reader), latest);
2610 assert!(reader.root_reads >= 4, "the ABA path must consume a retry");
2611 }
2612
2613 #[test]
2614 fn legacy_nonce_chunks_survive_the_first_v3_root_swap_then_recover() {
2615 let old = "legacy-".repeat(700);
2616 let replacement = "replacement-".repeat(500);
2617 let followup = "followup-".repeat(500);
2618 let backend = legacy_v2(&old, "legacy-nonce");
2619
2620 let mut reader = InterleavingReader::new(backend, vec![replacement.as_str()]);
2621 assert_eq!(read(&mut reader), old);
2622 assert!(reader
2623 .inner
2624 .entries
2625 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2626 assert!(reader
2627 .inner
2628 .entries
2629 .contains_key(&WindowsCredentialSlot::LegacyV2Chunk {
2630 nonce: "legacy-nonce".to_string(),
2631 index: 0,
2632 }));
2633
2634 publish(&mut reader.inner, &followup);
2635 assert!(!reader
2636 .inner
2637 .entries
2638 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2639 assert!(!reader.inner.entries.keys().any(|slot| matches!(
2640 slot,
2641 WindowsCredentialSlot::LegacyV2Chunk { nonce, .. } if nonce == "legacy-nonce"
2642 )));
2643 }
2644
2645 #[test]
2646 fn crash_after_every_publish_mutation_preserves_a_readable_generation() {
2647 let old = "old-".repeat(1200);
2648 let current = "current-".repeat(900);
2649 let replacement = "replacement-".repeat(300);
2650 let mut base = MemoryWindowsBackend::default();
2651 publish(&mut base, &old);
2652 publish(&mut base, ¤t);
2653 base.reset_faults();
2654
2655 let mut successful = base.clone();
2656 publish(&mut successful, &replacement);
2657 let mutation_count = successful.mutation_calls;
2658 assert!(mutation_count >= 7, "exercise stage, commit, and cleanup");
2659
2660 for crash_after in 1..=mutation_count {
2661 let mut crashed = base.clone();
2662 crashed.crash_after_mutation = Some(crash_after);
2663 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2664 let _ = publish_windows_value(&mut crashed, &replacement);
2665 }));
2666 assert!(unwind.is_err(), "mutation {crash_after} must crash");
2667 crashed.reset_faults();
2668
2669 let observed = read(&mut crashed);
2670 assert!(
2671 observed == current || observed == replacement,
2672 "crash {crash_after} exposed neither committed generation"
2673 );
2674
2675 publish(&mut crashed, &replacement);
2676 publish(&mut crashed, "recovery-pass");
2677 publish(&mut crashed, &replacement);
2678 assert_eq!(read(&mut crashed), replacement);
2679 assert!(
2680 crashed.entries.len() <= 20,
2681 "crash {crash_after} leaked unbounded entries: {:?}",
2682 crashed.entries.keys().collect::<Vec<_>>()
2683 );
2684 }
2685 }
2686
2687 #[test]
2688 fn repeated_precommit_crashes_have_bounded_cardinality_and_recover_cleanup() {
2689 let old = "old-".repeat(900);
2690 let attempted = "attempted-".repeat(900);
2691 let recovered = "ok-".repeat(600);
2692 let attempted_chunks = split_on_chars(&attempted, CHUNK_CHARS).len();
2693 let old_chunks = split_on_chars(&old, CHUNK_CHARS).len();
2694 let mut backend = MemoryWindowsBackend::default();
2695 publish(&mut backend, &old);
2696
2697 for crash_index in 0..64 {
2698 backend.reset_faults();
2699 backend.crash_after_mutation = Some(1 + crash_index % attempted_chunks);
2700 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2701 let _ = publish_windows_value(&mut backend, &attempted);
2702 }));
2703 assert!(
2704 backend.entries.len() <= 1 + 2 + old_chunks + attempted_chunks,
2705 "attempt {crash_index} grew deterministic storage"
2706 );
2707 }
2708
2709 backend.reset_faults();
2710 publish(&mut backend, &recovered);
2711 assert_eq!(read(&mut backend), recovered);
2712 let recovered_chunks = split_on_chars(&recovered, CHUNK_CHARS).len();
2713 assert!(!backend.entries.keys().any(|slot| matches!(
2714 slot,
2715 WindowsCredentialSlot::V3Chunk {
2716 generation: ChunkGeneration::B,
2717 index,
2718 } if *index >= recovered_chunks
2719 )));
2720 assert_eq!(
2721 backend
2722 .entries
2723 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::B)),
2724 Some(&recovered_chunks.to_string())
2725 );
2726 }
2727
2728 #[test]
2729 fn staging_and_root_failures_leave_the_only_good_generation_readable() {
2730 let old = "old-".repeat(900);
2731 let replacement = "replacement-".repeat(500);
2732 for failed_slot in [
2733 WindowsCredentialSlot::V3Chunk {
2734 generation: ChunkGeneration::B,
2735 index: 1,
2736 },
2737 WindowsCredentialSlot::Root,
2738 ] {
2739 let mut backend = MemoryWindowsBackend::default();
2740 publish(&mut backend, &old);
2741 backend.fail_write = Some(FailureRule {
2742 slot: failed_slot,
2743 matches_to_skip: 0,
2744 });
2745
2746 let error = publish_windows_value(&mut backend, &replacement).unwrap_err();
2747 assert_backend_error(error, "injected");
2748 assert_eq!(read(&mut backend), old);
2749 }
2750 }
2751
2752 #[test]
2753 fn postcommit_cleanup_errors_report_deferred_success_and_recover_later() {
2754 let old = "old-".repeat(1400);
2755 let current = "current-".repeat(900);
2756 let replacement = "replacement-".repeat(200);
2757 let mut backend = MemoryWindowsBackend::default();
2758 publish(&mut backend, &old);
2759 publish(&mut backend, ¤t);
2760 backend.fail_delete = Some(FailureRule {
2761 slot: WindowsCredentialSlot::V3Chunk {
2762 generation: ChunkGeneration::A,
2763 index: 4,
2764 },
2765 matches_to_skip: 0,
2766 });
2767
2768 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2769 assert_eq!(cleanup.failures, 1);
2770 assert_eq!(read(&mut backend), replacement);
2771 assert_eq!(
2772 backend
2773 .entries
2774 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2775 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string()),
2776 "failed cleanup keeps the crash high-water for a later sweep"
2777 );
2778
2779 publish(&mut backend, "rotate-once");
2780 publish(&mut backend, &replacement);
2781 assert!(!backend.entries.keys().any(|slot| matches!(
2782 slot,
2783 WindowsCredentialSlot::V3Chunk {
2784 generation: ChunkGeneration::A,
2785 index,
2786 } if *index >= split_on_chars(&replacement, CHUNK_CHARS).len()
2787 )));
2788 }
2789
2790 #[test]
2791 fn postcommit_manifest_shrink_failure_keeps_recovery_high_water() {
2792 let old = "old-".repeat(1400);
2793 let current = "current-".repeat(900);
2794 let replacement = "replacement-".repeat(200);
2795 let mut backend = MemoryWindowsBackend::default();
2796 publish(&mut backend, &old);
2797 publish(&mut backend, ¤t);
2798 backend.fail_write = Some(FailureRule {
2799 slot: WindowsCredentialSlot::V3Manifest(ChunkGeneration::A),
2800 matches_to_skip: 1,
2801 });
2802
2803 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2804 assert_eq!(cleanup.failures, 1);
2805 assert_eq!(read(&mut backend), replacement);
2806 assert_eq!(
2807 backend
2808 .entries
2809 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2810 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string())
2811 );
2812 }
2813
2814 #[test]
2815 fn delete_cleanup_failure_retains_manifest_for_idempotent_recovery() {
2816 let value = "secret-".repeat(700);
2817 let mut backend = MemoryWindowsBackend::default();
2818 publish(&mut backend, &value);
2819 backend.fail_delete = Some(FailureRule {
2820 slot: WindowsCredentialSlot::V3Chunk {
2821 generation: ChunkGeneration::A,
2822 index: 0,
2823 },
2824 matches_to_skip: 0,
2825 });
2826
2827 let cleanup = delete_windows_value(&mut backend).unwrap();
2828 assert_eq!(cleanup.failures, 1);
2829 assert!(!backend.entries.contains_key(&WindowsCredentialSlot::Root));
2830 assert!(backend
2831 .entries
2832 .contains_key(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)));
2833
2834 backend.reset_faults();
2835 assert_eq!(delete_windows_value(&mut backend).unwrap().failures, 0);
2836 assert!(backend.entries.is_empty());
2837 }
2838
2839 #[test]
2840 fn corrupt_cleanup_metadata_fails_before_root_or_chunks_are_deleted() {
2841 let old = "old-".repeat(900);
2842 let mut backend = MemoryWindowsBackend::default();
2843 publish(&mut backend, &old);
2844 let root_before = backend.root();
2845 backend.entries.insert(
2846 WindowsCredentialSlot::V3Manifest(ChunkGeneration::B),
2847 "not-a-count".to_string(),
2848 );
2849
2850 let error = publish_windows_value(&mut backend, "replacement").unwrap_err();
2851 assert_backend_error(error, "manifest");
2852 assert_eq!(backend.root(), root_before);
2853 assert_eq!(read(&mut backend), old);
2854
2855 let error = delete_windows_value(&mut backend).unwrap_err();
2856 assert_backend_error(error, "manifest");
2857 assert_eq!(backend.root(), root_before);
2858 assert_eq!(read(&mut backend), old);
2859 }
2860
2861 #[test]
2862 fn reader_retry_is_bounded_when_root_never_stabilizes() {
2863 let value_a = "a".repeat(2500);
2864 let value_b = "b".repeat(2500);
2865 let mut backend = MemoryWindowsBackend::default();
2866 publish(&mut backend, &value_a);
2867 let root_a = backend.root();
2868 publish(&mut backend, &value_b);
2869 let root_b = backend.root();
2870 backend.entries.remove(&WindowsCredentialSlot::V3Chunk {
2871 generation: ChunkGeneration::A,
2872 index: 0,
2873 });
2874 let mut churning = AlternatingRootBackend {
2875 inner: backend,
2876 roots: [root_a, root_b],
2877 root_reads: 0,
2878 };
2879
2880 let error = read_windows_value(&mut churning).unwrap_err();
2881 assert_backend_error(error, "changed during every read attempt");
2882 assert_eq!(churning.root_reads, WINDOWS_READ_ATTEMPTS * 2);
2883 }
2884
2885 struct InterleavingReader<'a> {
2886 inner: MemoryWindowsBackend,
2887 publications: Vec<&'a str>,
2888 root_reads: usize,
2889 }
2890
2891 impl<'a> InterleavingReader<'a> {
2892 fn new(inner: MemoryWindowsBackend, publications: Vec<&'a str>) -> Self {
2893 Self {
2894 inner,
2895 publications,
2896 root_reads: 0,
2897 }
2898 }
2899 }
2900
2901 impl WindowsCredentialBackend for InterleavingReader<'_> {
2902 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2903 let captured = self.inner.read(slot)?;
2904 if slot == &WindowsCredentialSlot::Root && self.root_reads == 0 {
2905 for value in self.publications.drain(..) {
2906 publish_windows_value(&mut self.inner, value)?;
2907 }
2908 }
2909 if slot == &WindowsCredentialSlot::Root {
2910 self.root_reads += 1;
2911 }
2912 Ok(captured)
2913 }
2914
2915 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2916 self.inner.write(slot, value)
2917 }
2918
2919 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2920 self.inner.delete(slot)
2921 }
2922 }
2923
2924 struct AlternatingRootBackend {
2925 inner: MemoryWindowsBackend,
2926 roots: [String; 2],
2927 root_reads: usize,
2928 }
2929
2930 impl WindowsCredentialBackend for AlternatingRootBackend {
2931 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2932 if slot == &WindowsCredentialSlot::Root {
2933 let root = self.roots[self.root_reads % self.roots.len()].clone();
2934 self.root_reads += 1;
2935 return Ok(Some(root));
2936 }
2937 self.inner.read(slot)
2938 }
2939
2940 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2941 self.inner.write(slot, value)
2942 }
2943
2944 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2945 self.inner.delete(slot)
2946 }
2947 }
2948}
2949
2950#[cfg(test)]
2951mod tests {
2952 use super::*;
2953 use serde::{Deserialize, Serialize};
2954
2955 #[test]
2966 fn a_store_that_is_not_a_directory_is_a_backend_error_not_a_missing_secret() {
2967 let parent = tempfile::tempdir().unwrap();
2968 let not_a_dir = parent.path().join("blocked");
2969 std::fs::write(¬_a_dir, b"a regular file where the store should be").unwrap();
2970 let reference = SecretRef::with_default_service("SOME_KEY");
2971
2972 assert!(
2973 !file_backend_entry_is_merely_absent(¬_a_dir),
2974 "the platform-neutral discriminator must reject a regular-file store root"
2975 );
2976
2977 match file_backend_get(¬_a_dir, &reference) {
2978 Err(SecretError::Backend(_)) => {}
2979 other => panic!("unusable store must report a backend error, got {other:?}"),
2980 }
2981 match file_backend_delete(¬_a_dir, &reference) {
2982 Err(SecretError::Backend(_)) => {}
2983 other => panic!("unusable store must not report a successful delete, got {other:?}"),
2984 }
2985 assert!(
2986 !file_backend_status(¬_a_dir, &reference).exists,
2987 "status on an unusable store must not claim knowledge of the entry"
2988 );
2989 }
2990
2991 #[test]
2994 fn a_store_directory_that_does_not_exist_yet_is_still_not_found() {
2995 let parent = tempfile::tempdir().unwrap();
2996 let never_created = parent.path().join("not-created-yet");
2997 assert!(!never_created.exists());
2998 assert!(
2999 file_backend_entry_is_merely_absent(&never_created),
3000 "a missing directory beneath an existing directory is a normal first run"
3001 );
3002 let reference = SecretRef::with_default_service("SOME_KEY");
3003
3004 match file_backend_get(&never_created, &reference) {
3005 Err(SecretError::NotFound { .. }) => {}
3006 other => panic!("a first-run store has no secrets, it is not broken: {other:?}"),
3007 }
3008 assert!(
3009 file_backend_delete(&never_created, &reference).is_ok(),
3010 "deleting from a store that was never written is a no-op success"
3011 );
3012 assert!(!file_backend_status(&never_created, &reference).exists);
3013 }
3014
3015 #[test]
3016 fn a_missing_entry_in_a_real_directory_is_still_not_found() {
3017 let dir = tempfile::tempdir().unwrap();
3018 let reference = SecretRef::with_default_service("ABSENT_KEY");
3019
3020 match file_backend_get(dir.path(), &reference) {
3021 Err(SecretError::NotFound { .. }) => {}
3022 other => panic!("an absent entry in a usable store is NotFound, got {other:?}"),
3023 }
3024 assert!(
3025 file_backend_delete(dir.path(), &reference).is_ok(),
3026 "deleting an absent entry from a usable store is a no-op success"
3027 );
3028 assert!(!file_backend_status(dir.path(), &reference).exists);
3029 }
3030
3031 static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3037
3038 fn lock_store() -> std::sync::MutexGuard<'static, ()> {
3039 STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
3040 }
3041
3042 struct IsolatedStoreFixture {
3047 _guard: std::sync::MutexGuard<'static, ()>,
3048 _dir: tempfile::TempDir,
3049 previous_dir: Option<std::ffi::OsString>,
3050 }
3051
3052 impl IsolatedStoreFixture {
3053 fn new() -> Self {
3054 let guard = lock_store();
3055 let dir = tempfile::tempdir().expect("isolated secret-store directory");
3056 let previous_dir = std::env::var_os("CAR_SECRETS_FILE_DIR");
3057 std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3058 assert_eq!(
3059 file_backend_dir().as_deref(),
3060 Some(dir.path()),
3061 "contract test must use the isolated file backend"
3062 );
3063 Self {
3064 _guard: guard,
3065 _dir: dir,
3066 previous_dir,
3067 }
3068 }
3069
3070 fn store(&self) -> SecretStore {
3071 SecretStore::new()
3072 }
3073 }
3074
3075 impl Drop for IsolatedStoreFixture {
3076 fn drop(&mut self) {
3077 match self.previous_dir.take() {
3078 Some(value) => std::env::set_var("CAR_SECRETS_FILE_DIR", value),
3079 None => std::env::remove_var("CAR_SECRETS_FILE_DIR"),
3080 }
3081 }
3082 }
3083
3084 #[cfg(target_os = "macos")]
3087 fn test_service() -> String {
3088 format!(
3089 "car-secrets-tests-{}-{}",
3090 std::process::id(),
3091 std::time::SystemTime::now()
3094 .duration_since(std::time::UNIX_EPOCH)
3095 .map(|d| d.as_nanos())
3096 .unwrap_or(0)
3097 )
3098 }
3099
3100 #[cfg(target_os = "macos")]
3101 const NATIVE_KEYCHAIN_LANE: &str = "CAR_TEST_NATIVE_KEYCHAIN";
3102
3103 #[cfg(target_os = "macos")]
3104 fn run_native_keychain_lane() {
3105 assert!(
3106 std::env::var_os("CAR_SECRETS_FILE_DIR").is_none(),
3107 "native lane refuses CAR_SECRETS_FILE_DIR; run it against the provisioned keychain"
3108 );
3109 let store = SecretStore::new();
3110 let availability = store.availability();
3111 assert!(
3112 availability.available,
3113 "native keychain unavailable: {}",
3114 availability
3115 .reason
3116 .unwrap_or_else(|| "no reason reported".to_string())
3117 );
3118
3119 #[derive(Serialize, Deserialize, PartialEq, Debug)]
3120 struct Session {
3121 cookies: Vec<String>,
3122 expires_at: i64,
3123 }
3124
3125 let reference = SecretRef::new(test_service(), "provisioned-native-contracts");
3126 store
3127 .delete(&reference)
3128 .expect("clean native fixture before run");
3129 assert!(matches!(
3130 store.get(&reference),
3131 Err(SecretError::NotFound { .. })
3132 ));
3133 store.put(&reference, "abc\n").expect("write native secret");
3134 assert_eq!(store.get(&reference).unwrap(), "abc\n");
3135 let status = store.status(&reference).unwrap();
3136 assert!(status.exists);
3137 assert!(!serde_json::to_string(&status).unwrap().contains("abc"));
3138 let session = Session {
3139 cookies: vec!["a=1".into(), "b=2".into()],
3140 expires_at: 1_700_000_000,
3141 };
3142 store.put_json(&reference, &session).unwrap();
3143 assert_eq!(store.get_json::<Session>(&reference).unwrap(), session);
3144 store
3145 .delete(&reference)
3146 .expect("clean native fixture after run");
3147 store
3148 .delete(&reference)
3149 .expect("native delete is idempotent");
3150 assert!(!store.status(&reference).unwrap().exists);
3151 }
3152
3153 #[derive(Clone)]
3167 struct BufWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3168
3169 impl std::io::Write for BufWriter {
3170 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3171 self.0.lock().unwrap().extend_from_slice(buf);
3172 Ok(buf.len())
3173 }
3174 fn flush(&mut self) -> std::io::Result<()> {
3175 Ok(())
3176 }
3177 }
3178
3179 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
3180 type Writer = BufWriter;
3181 fn make_writer(&'a self) -> Self::Writer {
3182 self.clone()
3183 }
3184 }
3185
3186 #[test]
3187 fn file_backend_roundtrip_and_warn_under_debug() {
3188 const CHILD: &str = "CAR_TEST_FILE_BACKEND_WARNING_CHILD";
3189 if std::env::var_os(CHILD).is_none() {
3190 let output =
3191 std::process::Command::new(std::env::current_exe().expect("test executable"))
3192 .args([
3193 "--exact",
3194 "tests::file_backend_roundtrip_and_warn_under_debug",
3195 "--nocapture",
3196 ])
3197 .env(CHILD, "1")
3198 .env_remove("CAR_SECRETS_FILE_DIR")
3199 .env_remove("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING")
3200 .env_remove("CAR_KEYCHAIN_PROOF_ROOT")
3201 .env_remove("CAR_KEYCHAIN_PATH")
3202 .output()
3203 .expect("spawn isolated file-backend warning test");
3204 assert!(
3205 output.status.success(),
3206 "isolated file-backend warning test failed\nstdout:\n{}\nstderr:\n{}",
3207 String::from_utf8_lossy(&output.stdout),
3208 String::from_utf8_lossy(&output.stderr),
3209 );
3210 return;
3211 }
3212
3213 if std::env::var_os("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING").is_some() {
3217 let _ = file_backend_dir();
3218 }
3219 let _guard = lock_store();
3222 #[allow(clippy::assertions_on_constants)]
3226 {
3227 assert!(
3228 cfg!(debug_assertions),
3229 "the crate test suite runs in debug; the file backend depends on it"
3230 );
3231 }
3232
3233 let dir = std::env::temp_dir().join(format!(
3234 "car-secrets-filebackend-{}-{}",
3235 std::process::id(),
3236 std::time::SystemTime::now()
3237 .duration_since(std::time::UNIX_EPOCH)
3238 .map(|d| d.as_nanos())
3239 .unwrap_or(0)
3240 ));
3241 std::fs::create_dir_all(&dir).unwrap();
3242 std::env::set_var("CAR_SECRETS_FILE_DIR", &dir);
3243
3244 let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
3247 let subscriber = tracing_subscriber::fmt()
3248 .with_writer(BufWriter(buf.clone()))
3249 .with_max_level(tracing::Level::WARN)
3250 .finish();
3251 tracing::subscriber::with_default(subscriber, || {
3252 assert_eq!(
3255 file_backend_dir().as_deref(),
3256 Some(dir.as_path()),
3257 "CAR_SECRETS_FILE_DIR must be honored under debug_assertions"
3258 );
3259 });
3260 let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3261 assert!(
3262 logged.contains("PLAINTEXT ON DISK"),
3263 "the file backend must emit the one-time PLAINTEXT warning, got logs: {logged:?}"
3264 );
3265
3266 let store = SecretStore::new();
3267 let check = store.availability();
3269 assert!(check.available, "file backend must report available");
3270 assert!(check.reason.is_none());
3271
3272 let r = SecretRef::new("svc", "key");
3274 store.put(&r, "xoxb-plaintext-value").unwrap();
3275 assert_eq!(store.get(&r).unwrap(), "xoxb-plaintext-value");
3276 let on_disk = std::fs::read_to_string(file_backend_path(&dir, &r)).unwrap();
3278 assert_eq!(on_disk, "xoxb-plaintext-value");
3279 store.delete(&r).unwrap();
3280 match store.get(&r) {
3281 Err(SecretError::NotFound { .. }) => {}
3282 other => panic!("expected NotFound after delete, got {other:?}"),
3283 }
3284
3285 for key in [
3289 OPENROUTER_OAUTH_KEY,
3290 PARSLEE_ACCESS_TOKEN_KEY,
3291 PARSLEE_REFRESH_TOKEN_KEY,
3292 PARSLEE_EXPIRES_AT_KEY,
3293 PARSLEE_API_BASE_KEY,
3294 PARSLEE_ACCOUNTS_KEY,
3295 "PARSLEE_TOKENS_account-1",
3296 PARSLEE_AUTH_GENERATION_KEY,
3297 PARSLEE_AUTH_COMPLETION_KEY,
3298 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3299 PARSLEE_AUTH_STATE_V2_KEY,
3300 ] {
3301 let private = SecretRef::new(DEFAULT_SERVICE, key);
3302 assert!(is_daemon_private_secret(&private.service, &private.key));
3303 store.put(&private, "internal-test-value").unwrap();
3304 assert_eq!(store.get(&private).unwrap(), "internal-test-value");
3305 store.delete(&private).unwrap();
3306 assert!(matches!(
3307 store.get(&private),
3308 Err(SecretError::NotFound { .. })
3309 ));
3310 }
3311
3312 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3314 let _ = std::fs::remove_dir_all(&dir);
3315 }
3316
3317 #[test]
3318 fn every_parslee_auth_slot_is_private_to_the_dedicated_auth_surface() {
3319 for key in [
3320 PARSLEE_ACCESS_TOKEN_KEY,
3321 PARSLEE_REFRESH_TOKEN_KEY,
3322 PARSLEE_EXPIRES_AT_KEY,
3323 PARSLEE_API_BASE_KEY,
3324 PARSLEE_ACCOUNTS_KEY,
3325 "PARSLEE_TOKENS_account-1",
3326 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3327 PARSLEE_AUTH_GENERATION_KEY,
3328 PARSLEE_AUTH_COMPLETION_KEY,
3329 PARSLEE_AUTH_STATE_V2_KEY,
3330 ] {
3331 assert!(
3332 is_daemon_private_secret(DEFAULT_SERVICE, key),
3333 "{key} must be unreachable through generic secret surfaces"
3334 );
3335 assert!(
3336 !is_daemon_private_secret("other-service", key),
3337 "reservation must remain scoped to the CAR service"
3338 );
3339 }
3340
3341 assert!(!is_daemon_private_secret(
3342 DEFAULT_SERVICE,
3343 "OPENROUTER_API_KEY"
3344 ));
3345 for key in [
3346 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunk0"),
3347 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunkv2#nonce-1#0"),
3348 format!("{OPENROUTER_OAUTH_KEY}#chunk17"),
3349 format!("{OPENROUTER_OAUTH_KEY}#chunkv2#nonce-2#3"),
3350 ] {
3351 assert!(
3352 is_daemon_private_secret(DEFAULT_SERVICE, &key),
3353 "{key} is derived from a daemon-private root"
3354 );
3355 assert!(!is_daemon_private_secret("other-service", &key));
3356 }
3357 }
3358
3359 #[cfg(target_os = "macos")]
3360 #[test]
3361 fn bounded_command_output_large_helper() {
3362 if std::env::var_os("CAR_SECURITY_OUTPUT_HELPER").is_none() {
3363 return;
3364 }
3365 use std::io::Write;
3366 let payload = vec![b'x'; 128 * 1024];
3367 std::io::stdout().write_all(&payload).unwrap();
3368 std::io::stdout().flush().unwrap();
3369 std::io::stderr().write_all(&payload).unwrap();
3370 std::io::stderr().flush().unwrap();
3371 }
3372
3373 #[cfg(target_os = "macos")]
3374 #[test]
3375 fn bounded_command_output_drains_large_stdout_and_stderr() {
3376 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3377 command
3378 .args([
3379 "--exact",
3380 "tests::bounded_command_output_large_helper",
3381 "--nocapture",
3382 ])
3383 .env("CAR_SECURITY_OUTPUT_HELPER", "1");
3384
3385 let output =
3386 bounded_command_output(&mut command, std::time::Duration::from_secs(5), "test")
3387 .unwrap();
3388
3389 assert!(output.output.status.success(), "{output:?}");
3390 assert!(output.output.stdout.len() >= 128 * 1024);
3391 assert!(output.output.stderr.len() >= 128 * 1024);
3392 }
3393
3394 #[cfg(target_os = "macos")]
3395 pub(super) struct FakeSecurityCli {
3396 outputs: std::cell::RefCell<std::collections::VecDeque<std::io::Result<SecurityCliOutput>>>,
3397 calls: std::cell::RefCell<Vec<Vec<String>>>,
3398 }
3399
3400 #[cfg(target_os = "macos")]
3401 impl FakeSecurityCli {
3402 pub(super) fn new(outputs: Vec<std::io::Result<SecurityCliOutput>>) -> Self {
3403 Self {
3404 outputs: std::cell::RefCell::new(outputs.into()),
3405 calls: std::cell::RefCell::new(Vec::new()),
3406 }
3407 }
3408
3409 pub(super) fn calls(&self) -> Vec<Vec<String>> {
3410 self.calls.borrow().clone()
3411 }
3412 }
3413
3414 #[cfg(target_os = "macos")]
3415 impl SecurityCli for FakeSecurityCli {
3416 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
3417 self.calls
3418 .borrow_mut()
3419 .push(args.iter().map(|arg| (*arg).to_string()).collect());
3420 self.outputs
3421 .borrow_mut()
3422 .pop_front()
3423 .expect("missing fake security output")
3424 }
3425 }
3426
3427 #[cfg(target_os = "macos")]
3428 pub(super) fn security_output(
3429 code: i32,
3430 stdout: impl Into<Vec<u8>>,
3431 stderr: impl Into<Vec<u8>>,
3432 ) -> std::io::Result<SecurityCliOutput> {
3433 Ok(SecurityCliOutput {
3434 success: code == 0,
3435 code: Some(code),
3436 stdout: stdout.into(),
3437 stderr: stderr.into(),
3438 prompted: false,
3439 timed_out: false,
3440 })
3441 }
3442
3443 #[cfg(target_os = "macos")]
3446 pub(super) fn security_output_prompted(
3447 code: i32,
3448 stdout: impl Into<Vec<u8>>,
3449 stderr: impl Into<Vec<u8>>,
3450 ) -> std::io::Result<SecurityCliOutput> {
3451 let mut out = security_output(code, stdout, stderr)?;
3452 out.prompted = true;
3453 Ok(out)
3454 }
3455
3456 #[cfg(target_os = "macos")]
3457 fn args(values: &[&str]) -> Vec<String> {
3458 values.iter().map(|value| (*value).to_string()).collect()
3459 }
3460
3461 #[cfg(target_os = "macos")]
3465 #[test]
3466 fn availability_probe_goes_through_the_security_helper() {
3467 let cli = FakeSecurityCli::new(vec![
3468 security_output(0, "", ""),
3469 security_output(0, "", ""),
3470 security_output(0, "", ""),
3471 ]);
3472 let check = mac_availability_via_security_cli_with(&cli);
3473
3474 assert!(check.available);
3475 assert_eq!(
3476 cli.calls(),
3477 vec![
3478 args(&[
3479 "find-generic-password",
3480 "-s",
3481 "car-internal",
3482 "-a",
3483 "__availability_probe__",
3484 ]),
3485 args(&[
3486 "add-generic-password",
3487 "-U",
3488 "-A",
3489 "-s",
3490 "car-internal",
3491 "-a",
3492 "__availability_probe__",
3493 "-w",
3494 "car-availability-probe",
3495 ]),
3496 args(&[
3497 "delete-generic-password",
3498 "-s",
3499 "car-internal",
3500 "-a",
3501 "__availability_probe__",
3502 ]),
3503 ]
3504 );
3505 }
3506
3507 #[cfg(target_os = "macos")]
3510 #[test]
3511 fn availability_probe_absent_cleanup_is_still_available() {
3512 let cli = FakeSecurityCli::new(vec![
3513 security_output(SECURITY_ERR_SEC_ITEM_NOT_FOUND, "", ""),
3514 security_output(0, "", ""),
3515 security_output(SECURITY_ERR_SEC_ITEM_NOT_FOUND, "", ""),
3516 ]);
3517 let check = mac_availability_via_security_cli_with(&cli);
3518
3519 assert!(check.available);
3520 assert!(check.reason.is_none(), "{:?}", check.reason);
3521 }
3522
3523 #[cfg(target_os = "macos")]
3527 #[test]
3528 fn availability_probe_reports_unavailable_when_reads_succeed_but_writes_are_denied() {
3529 struct ReadableButWriteDeniedCli;
3530
3531 impl SecurityCli for ReadableButWriteDeniedCli {
3532 fn output(&self, args: &[&str]) -> std::io::Result<SecurityCliOutput> {
3533 match args.first().copied() {
3534 Some("find-generic-password") => security_output(0, "", ""),
3535 Some("add-generic-password") => security_output(
3536 152,
3537 "",
3538 "security: SecKeychainItemCreateFromContent: User interaction is not allowed.",
3539 ),
3540 other => panic!("unexpected security command: {other:?}"),
3541 }
3542 }
3543 }
3544
3545 let check = mac_availability_via_security_cli_with(&ReadableButWriteDeniedCli);
3546
3547 assert!(!check.available);
3548 let reason = check
3549 .reason
3550 .expect("write-denied probe must carry a reason");
3551 assert!(
3552 reason.contains("User interaction is not allowed"),
3553 "reason should carry the write error, got {reason:?}"
3554 );
3555 }
3556
3557 #[cfg(target_os = "macos")]
3561 #[test]
3562 fn availability_probe_backend_error_reports_unavailable() {
3563 let cli = FakeSecurityCli::new(vec![security_output(
3564 51,
3565 "",
3566 "security: SecKeychainSearchCopyNext: User interaction is not allowed.",
3567 )]);
3568 let check = mac_availability_via_security_cli_with(&cli);
3569
3570 assert!(!check.available);
3571 let reason = check.reason.expect("unavailable must carry a reason");
3572 assert!(
3573 reason.contains("User interaction is not allowed"),
3574 "reason should carry the helper's stderr, got {reason:?}"
3575 );
3576 }
3577
3578 #[cfg(target_os = "macos")]
3582 #[test]
3583 fn availability_probe_cleanup_error_reports_unavailable() {
3584 let cli = FakeSecurityCli::new(vec![
3585 security_output(0, "", ""),
3586 security_output(0, "", ""),
3587 security_output(
3588 51,
3589 "",
3590 "security: SecKeychainItemDelete: User interaction is not allowed.",
3591 ),
3592 ]);
3593 let check = mac_availability_via_security_cli_with(&cli);
3594
3595 assert!(!check.available);
3596 let reason = check.reason.expect("cleanup failure must carry a reason");
3597 assert!(
3598 reason.contains("User interaction is not allowed"),
3599 "reason should carry the cleanup error, got {reason:?}"
3600 );
3601 }
3602
3603 #[cfg(target_os = "macos")]
3608 #[test]
3609 fn availability_probe_names_itself_in_the_prompt_notice() {
3610 let cli = FakeSecurityCli::new(vec![security_output(
3611 51,
3612 "",
3613 "security: SecKeychainSearchCopyNext: User interaction is not allowed.",
3614 )]);
3615 let _ = mac_availability_via_security_cli_with(&cli);
3616
3617 let sent = cli.calls().remove(0);
3623 let sent: Vec<&str> = sent.iter().map(String::as_str).collect();
3624 let item = describe_item(&sent);
3625
3626 assert_eq!(item, "car-internal/__availability_probe__");
3627 assert!(
3628 keychain_prompt_notice(&item).contains(&item),
3629 "notice must name the blocking item: {}",
3630 keychain_prompt_notice(&item)
3631 );
3632 }
3633
3634 #[cfg(target_os = "macos")]
3635 fn assert_access_denied_contains(err: SecretError, expected: &str) {
3636 match err {
3637 SecretError::AccessDenied { message } => assert!(
3638 message.contains(expected),
3639 "expected access-denied error to contain {expected:?}, got {message:?}"
3640 ),
3641 other => panic!("expected AccessDenied, got {:?}", other),
3642 }
3643 }
3644
3645 #[cfg(target_os = "macos")]
3646 #[test]
3647 fn mac_security_errors_are_typed_for_recovery() {
3648 assert!(matches!(
3649 classify_security_error(-128, "user canceled"),
3650 SecretError::UserCancelled { .. }
3651 ));
3652 assert!(matches!(
3653 classify_security_error(-25293, "authorization denied"),
3654 SecretError::AccessDenied { .. }
3655 ));
3656 assert!(matches!(
3657 classify_helper_timeout("car/PARSLEE_AUTH_STATE_V2"),
3658 SecretError::HelperTimedOut { .. }
3659 ));
3660
3661 let mut timed_out = security_output(9, b"", b"helper killed").unwrap();
3662 timed_out.timed_out = true;
3663 let cli = FakeSecurityCli::new(vec![Ok(timed_out)]);
3664 let secret = SecretRef::new("svc", "key");
3665 assert!(matches!(
3666 mac_get_via_security_cli_with(&secret, &cli),
3667 Err(SecretError::HelperTimedOut { .. })
3668 ));
3669 }
3670
3671 #[cfg(target_os = "macos")]
3672 struct IsolatedKeychainFixture {
3673 _temp: tempfile::TempDir,
3674 proof_root: std::path::PathBuf,
3675 valid_path: std::path::PathBuf,
3676 symlink_path: std::path::PathBuf,
3677 outside_path: std::path::PathBuf,
3678 public_path: std::path::PathBuf,
3679 directory_path: std::path::PathBuf,
3680 public_root: std::path::PathBuf,
3681 }
3682
3683 #[cfg(target_os = "macos")]
3684 impl IsolatedKeychainFixture {
3685 fn new() -> Self {
3686 use std::os::unix::fs::{symlink, PermissionsExt};
3687
3688 let temp = tempfile::tempdir().unwrap();
3689 let proof_root = temp.path().join("proof");
3690 std::fs::create_dir(&proof_root).unwrap();
3691 std::fs::set_permissions(&proof_root, std::fs::Permissions::from_mode(0o700)).unwrap();
3692
3693 let valid_path = proof_root.join("valid.keychain-db");
3694 std::fs::write(&valid_path, b"keychain fixture").unwrap();
3695 std::fs::set_permissions(&valid_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3696
3697 let symlink_path = proof_root.join("linked.keychain-db");
3698 symlink(&valid_path, &symlink_path).unwrap();
3699
3700 let outside_path = temp.path().join("outside.keychain-db");
3701 std::fs::write(&outside_path, b"outside fixture").unwrap();
3702 std::fs::set_permissions(&outside_path, std::fs::Permissions::from_mode(0o600))
3703 .unwrap();
3704
3705 let public_path = proof_root.join("public.keychain-db");
3706 std::fs::write(&public_path, b"public fixture").unwrap();
3707 std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3708
3709 let directory_path = proof_root.join("directory.keychain-db");
3710 std::fs::create_dir(&directory_path).unwrap();
3711
3712 let public_root = temp.path().join("public-proof");
3713 std::fs::create_dir(&public_root).unwrap();
3714 std::fs::set_permissions(&public_root, std::fs::Permissions::from_mode(0o755)).unwrap();
3715
3716 Self {
3717 _temp: temp,
3718 proof_root,
3719 valid_path,
3720 symlink_path,
3721 outside_path,
3722 public_path,
3723 directory_path,
3724 public_root,
3725 }
3726 }
3727
3728 fn proof_root(&self) -> &std::path::Path {
3729 &self.proof_root
3730 }
3731
3732 fn valid_path(&self) -> &std::path::Path {
3733 &self.valid_path
3734 }
3735
3736 fn symlink_path(&self) -> &std::path::Path {
3737 &self.symlink_path
3738 }
3739
3740 fn outside_path(&self) -> &std::path::Path {
3741 &self.outside_path
3742 }
3743
3744 fn public_path(&self) -> &std::path::Path {
3745 &self.public_path
3746 }
3747
3748 fn directory_path(&self) -> &std::path::Path {
3749 &self.directory_path
3750 }
3751
3752 fn public_root(&self) -> &std::path::Path {
3753 &self.public_root
3754 }
3755 }
3756
3757 #[cfg(target_os = "macos")]
3758 #[test]
3759 fn isolated_keychain_must_be_absolute_private_regular_owned_and_under_proof_root() {
3760 let fixture = IsolatedKeychainFixture::new();
3761 assert!(validate_keychain_path(fixture.valid_path(), fixture.proof_root()).is_ok());
3762 assert!(validate_keychain_path(
3763 std::path::Path::new("relative.keychain-db"),
3764 fixture.proof_root()
3765 )
3766 .is_err());
3767 assert!(validate_keychain_path(fixture.symlink_path(), fixture.proof_root()).is_err());
3768 assert!(validate_keychain_path(fixture.outside_path(), fixture.proof_root()).is_err());
3769 assert!(validate_keychain_path(fixture.public_path(), fixture.proof_root()).is_err());
3770 assert!(validate_keychain_path(fixture.directory_path(), fixture.proof_root()).is_err());
3771 assert!(validate_keychain_path(fixture.valid_path(), fixture.public_root()).is_err());
3772 }
3773
3774 #[test]
3775 fn secret_store_activity_counts_only_aggregate_public_operation_attempts() {
3776 let _guard = lock_store();
3777 let dir = tempfile::tempdir().unwrap();
3778 std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3779 let before = secret_store_activity();
3780 let store = SecretStore::new();
3781 let secret = SecretRef::new("activity-test", "credential");
3782
3783 assert!(store.availability().available);
3784 store.put(&secret, "sensitive-value").unwrap();
3785 let _ = store.get(&secret).unwrap();
3786 let _ = store.status(&secret).unwrap();
3787 store.publish(&secret, "replacement-value").unwrap();
3788 store.delete(&secret).unwrap();
3789
3790 let after = secret_store_activity();
3791 assert_eq!(after.get_attempts - before.get_attempts, 1);
3792 assert_eq!(after.status_attempts - before.status_attempts, 1);
3793 assert_eq!(
3794 after.availability_attempts - before.availability_attempts,
3795 1
3796 );
3797 assert_eq!(after.write_attempts - before.write_attempts, 2);
3798 assert_eq!(after.delete_attempts - before.delete_attempts, 1);
3799
3800 let encoded = serde_json::to_string(&after).unwrap();
3801 assert!(!encoded.contains("activity-test"));
3802 assert!(!encoded.contains("credential"));
3803 assert!(!encoded.contains("sensitive-value"));
3804 assert!(!encoded.contains(dir.path().to_string_lossy().as_ref()));
3805 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3806 }
3807
3808 #[test]
3817 fn roundtrip_string() {
3818 #[cfg(target_os = "macos")]
3819 if std::env::var_os(NATIVE_KEYCHAIN_LANE).is_some() {
3820 run_native_keychain_lane();
3821 return;
3822 }
3823
3824 let fixture = IsolatedStoreFixture::new();
3825 let store = fixture.store();
3826 let r = SecretRef::new("isolated-contract", "roundtrip");
3827 store.put(&r, "hello world").unwrap();
3828 assert_eq!(store.get(&r).unwrap(), "hello world");
3829 assert!(store.status(&r).unwrap().exists);
3830 store.delete(&r).unwrap();
3831 assert!(!store.status(&r).unwrap().exists);
3832 }
3833
3834 #[test]
3835 fn roundtrip_string_with_trailing_newline() {
3836 let fixture = IsolatedStoreFixture::new();
3837 let store = fixture.store();
3838 let r = SecretRef::new("isolated-contract", "roundtrip-newline");
3839 let value = "abc\n";
3840 store.put(&r, value).unwrap();
3841 assert_eq!(store.get(&r).unwrap(), value);
3842 store.delete(&r).unwrap();
3843 }
3844
3845 #[test]
3846 fn get_missing_returns_not_found() {
3847 let fixture = IsolatedStoreFixture::new();
3848 let store = fixture.store();
3849 let r = SecretRef::new("isolated-contract", "never-written");
3850 match store.get(&r) {
3851 Err(SecretError::NotFound { .. }) => (),
3852 other => panic!("expected NotFound, got {:?}", other),
3853 }
3854 }
3855
3856 #[test]
3857 fn delete_missing_is_idempotent() {
3858 let fixture = IsolatedStoreFixture::new();
3859 let store = fixture.store();
3860 let r = SecretRef::new("isolated-contract", "missing");
3861 store.delete(&r).unwrap();
3862 store.delete(&r).unwrap();
3863 }
3864
3865 #[test]
3866 fn json_roundtrip() {
3867 let fixture = IsolatedStoreFixture::new();
3868 #[derive(Serialize, Deserialize, PartialEq, Debug)]
3869 struct Session {
3870 cookies: Vec<String>,
3871 expires_at: i64,
3872 }
3873 let store = fixture.store();
3874 let r = SecretRef::new("isolated-contract", "session");
3875 let s = Session {
3876 cookies: vec!["a=1".into(), "b=2".into()],
3877 expires_at: 1_700_000_000,
3878 };
3879 store.put_json(&r, &s).unwrap();
3880 let back: Session = store.get_json(&r).unwrap();
3881 assert_eq!(back, s);
3882 store.delete(&r).unwrap();
3883 }
3884
3885 #[test]
3886 fn status_no_leak() {
3887 let fixture = IsolatedStoreFixture::new();
3888 let store = fixture.store();
3889 let r = SecretRef::new("isolated-contract", "status");
3890 store.put(&r, "secret-payload").unwrap();
3891 let st = store.status(&r).unwrap();
3892 let encoded = serde_json::to_string(&st).unwrap();
3893 assert!(!encoded.contains("secret-payload"));
3894 store.delete(&r).unwrap();
3895 }
3896
3897 #[cfg(target_os = "macos")]
3898 #[test]
3899 fn mac_get_uses_security_cli_and_maps_success() {
3900 let cli = FakeSecurityCli::new(vec![security_output(
3901 0,
3902 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3903 b"password: \"secret\"\n",
3904 )]);
3905 let r = SecretRef::new("svc", "key");
3906
3907 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
3908 assert_eq!(
3909 cli.calls(),
3910 vec![args(&[
3911 "find-generic-password",
3912 "-s",
3913 "svc",
3914 "-a",
3915 "key",
3916 "-g"
3917 ])]
3918 );
3919 }
3920
3921 #[cfg(target_os = "macos")]
3926 #[test]
3927 fn prompted_read_preserves_the_item_and_persisted_grant() {
3928 let cli = FakeSecurityCli::new(vec![security_output_prompted(
3929 0,
3930 b"keychain: isolated-test.keychain-db\n",
3931 b"password: \"secret\"\n",
3932 )]);
3933 let r = SecretRef::new("car-test-0o9-prompt-persistence", "credential");
3934
3935 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
3936 assert_eq!(
3937 cli.calls(),
3938 vec![args(&[
3939 "find-generic-password",
3940 "-s",
3941 "car-test-0o9-prompt-persistence",
3942 "-a",
3943 "credential",
3944 "-g",
3945 ])],
3946 "an approved read must never rewrite or recreate the item"
3947 );
3948 }
3949
3950 #[cfg(target_os = "macos")]
3951 #[test]
3952 fn mac_get_decodes_hex_password_output_with_trailing_newline() {
3953 let cli = FakeSecurityCli::new(vec![security_output(
3954 0,
3955 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
3956 b"password: 0x6162630A \"abc\\012\"\n",
3957 )]);
3958 let r = SecretRef::new("svc", "key");
3959
3960 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
3961 assert_eq!(
3962 cli.calls(),
3963 vec![args(&[
3964 "find-generic-password",
3965 "-s",
3966 "svc",
3967 "-a",
3968 "key",
3969 "-g"
3970 ])]
3971 );
3972 }
3973
3974 #[cfg(target_os = "macos")]
3975 #[test]
3976 fn mac_get_maps_not_found_and_access_denied_without_fallback() {
3977 let r = SecretRef::new("svc", "missing");
3978 let cli = FakeSecurityCli::new(vec![security_output(
3979 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
3980 b"",
3981 b"The specified item could not be found in the keychain.\n",
3982 )]);
3983
3984 match mac_get_via_security_cli_with(&r, &cli) {
3985 Err(SecretError::NotFound { service, key }) => {
3986 assert_eq!(service, "svc");
3987 assert_eq!(key, "missing");
3988 }
3989 other => panic!("expected NotFound, got {:?}", other),
3990 }
3991 assert_eq!(cli.calls().len(), 1);
3992
3993 let cli = FakeSecurityCli::new(vec![security_output(
3994 51,
3995 b"",
3996 b"User interaction is not allowed.\n",
3997 )]);
3998 let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
3999 assert_access_denied_contains(err, "User interaction is not allowed.");
4000 assert_eq!(cli.calls().len(), 1);
4001 }
4002
4003 #[cfg(target_os = "macos")]
4004 #[test]
4005 fn mac_status_uses_security_cli_and_maps_results() {
4006 let r = SecretRef::new("svc", "key");
4007 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4008
4009 let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
4010 assert!(status.exists);
4011 assert_eq!(
4012 cli.calls(),
4013 vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
4014 );
4015
4016 let cli = FakeSecurityCli::new(vec![security_output(
4017 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4018 b"",
4019 b"The specified item could not be found in the keychain.\n",
4020 )]);
4021 assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
4022
4023 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4024 let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
4025 assert_access_denied_contains(err, "auth denied");
4026 }
4027
4028 #[cfg(target_os = "macos")]
4029 #[test]
4030 fn mac_put_surfaces_add_failure_as_access_denied() {
4031 let cli = FakeSecurityCli::new(vec![security_output(
4032 51,
4033 b"",
4034 b"User interaction is not allowed.\n",
4035 )]);
4036
4037 let err =
4038 mac_write_via_security_cli("car-test-0o9-write", "key", "secret", &cli).unwrap_err();
4039 assert_access_denied_contains(err, "User interaction is not allowed.");
4040 assert_eq!(
4041 cli.calls().len(),
4042 1,
4043 "a failed write must not trigger a delete"
4044 );
4045 }
4046
4047 #[cfg(target_os = "macos")]
4053 #[test]
4054 fn mac_put_does_not_pre_delete_and_therefore_cannot_prompt() {
4055 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4056
4057 mac_put_via_security_cli_with("car-test-0o9-update-persistence", "key", "secret", &cli)
4058 .unwrap();
4059
4060 assert_eq!(
4061 cli.calls(),
4062 vec![args(&[
4063 "add-generic-password",
4064 "-U",
4065 "-A",
4066 "-s",
4067 "car-test-0o9-update-persistence",
4068 "-a",
4069 "key",
4070 "-w",
4071 "secret",
4072 ])],
4073 "an ordinary write must issue exactly one call, and not a delete"
4074 );
4075 }
4076
4077 #[cfg(target_os = "macos")]
4078 #[test]
4079 fn mac_publish_updates_in_place_without_a_pre_delete_gap() {
4080 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4081
4082 mac_publish_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4083
4084 assert_eq!(
4085 cli.calls(),
4086 vec![args(&[
4087 "add-generic-password",
4088 "-U",
4089 "-A",
4090 "-s",
4091 "svc",
4092 "-a",
4093 "key",
4094 "-w",
4095 "secret",
4096 ])]
4097 );
4098 }
4099
4100 #[cfg(target_os = "macos")]
4101 #[test]
4102 fn mac_security_child_is_killed_and_reaped_at_its_deadline() {
4103 let mut command = std::process::Command::new("/bin/sh");
4104 command.args(["-c", "sleep 5"]);
4105 let started = std::time::Instant::now();
4106
4107 let output = bounded_command_output_with(
4108 &mut command,
4109 std::time::Duration::from_millis(40),
4110 || false,
4111 || {},
4112 )
4113 .unwrap();
4114
4115 assert!(!output.output.status.success());
4116 assert!(
4117 started.elapsed() < std::time::Duration::from_secs(1),
4118 "bounded helper must not wait for the child command's natural exit"
4119 );
4120 let stderr = String::from_utf8_lossy(&output.output.stderr);
4121 assert!(stderr.contains("CAR killed the keychain helper"));
4122 assert!(
4128 stderr.contains("keychain prompt"),
4129 "the timeout must name a pending keychain prompt as the likely cause"
4130 );
4131 assert!(
4132 stderr.contains("Always Allow"),
4133 "the timeout must tell the user what action clears it"
4134 );
4135 }
4136
4137 #[cfg(target_os = "macos")]
4146 #[test]
4147 fn a_hung_helper_with_no_dialog_still_dies_at_the_short_deadline() {
4148 assert!(
4151 SECURITY_CLI_INTERACTIVE_TIMEOUT > SECURITY_CLI_TIMEOUT,
4152 "the interactive allowance must be longer than the hang deadline"
4153 );
4154 assert!(
4155 SECURITY_CLI_INTERACTIVE_TIMEOUT >= std::time::Duration::from_secs(60),
4156 "a human needs to find a window, type a password and submit — 15s \
4157 is why entering the correct password repeatedly never worked"
4158 );
4159
4160 let mut command = std::process::Command::new("/bin/sh");
4161 command.args(["-c", "sleep 5"]);
4162 let started = std::time::Instant::now();
4163 let output = bounded_command_output_with(
4164 &mut command,
4165 std::time::Duration::from_millis(40),
4166 || false,
4167 || {},
4168 )
4169 .unwrap();
4170 assert!(!output.output.status.success());
4171 assert!(
4172 started.elapsed() < std::time::Duration::from_secs(1),
4173 "a helper with no dialog must not inherit the interactive allowance"
4174 );
4175 }
4176
4177 #[cfg(target_os = "macos")]
4186 #[test]
4187 fn a_dialog_is_not_attributed_to_a_read_that_did_not_wait_for_it() {
4188 let instant = std::time::Duration::from_millis(0);
4189 let quick = std::time::Duration::from_millis(20);
4190
4191 assert!(
4192 !dialog_is_evidence_for_this_read(true, instant),
4193 "a dialog already on screen at spawn belongs to whatever opened it"
4194 );
4195 assert!(
4196 !dialog_is_evidence_for_this_read(true, quick),
4197 "a read that returned in 20ms was never blocked on a human"
4198 );
4199 assert!(
4200 !dialog_is_evidence_for_this_read(false, std::time::Duration::from_secs(60)),
4201 "no dialog is no evidence, however long the helper took"
4202 );
4203 assert!(
4204 dialog_is_evidence_for_this_read(true, PROMPT_EVIDENCE_MIN),
4205 "a call still blocked with a dialog up is the one being authorized"
4206 );
4207 }
4208
4209 #[cfg(target_os = "macos")]
4212 #[test]
4213 fn the_prompt_evidence_threshold_sits_between_a_silent_read_and_a_human() {
4214 assert!(
4215 PROMPT_EVIDENCE_MIN >= std::time::Duration::from_millis(200),
4216 "must be an order of magnitude above a silent `security -g` read, \
4217 which returns in tens of milliseconds"
4218 );
4219 assert!(
4220 PROMPT_EVIDENCE_MIN <= std::time::Duration::from_secs(2),
4221 "must stay below the fastest a human can answer a dialog, or the \
4222 blocking read finishes before CAR can explain what is waiting"
4223 );
4224 assert!(
4225 PROMPT_EVIDENCE_MIN < SECURITY_CLI_TIMEOUT,
4226 "a prompted read must be attributable before any deadline can end it"
4227 );
4228 }
4229
4230 #[cfg(target_os = "macos")]
4239 #[test]
4240 fn a_fast_helper_is_not_attributed_a_dialog_that_is_on_screen_throughout() {
4241 let mut command = std::process::Command::new("/bin/echo");
4242 command.arg("hi");
4243 let notices = std::sync::atomic::AtomicUsize::new(0);
4244 let run = bounded_command_output_with(
4245 &mut command,
4246 SECURITY_CLI_TIMEOUT,
4247 || true,
4248 || {
4249 notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4250 },
4251 )
4252 .unwrap();
4253 assert!(run.output.status.success());
4254 assert!(
4255 !run.prompted,
4256 "a helper that exited in milliseconds was not the one being authorized, \
4257 however many dialogs the machine is showing"
4258 );
4259 assert_eq!(
4260 notices.load(std::sync::atomic::Ordering::Relaxed),
4261 0,
4262 "and it must not tell the user to go answer a dialog it never waited on \
4263 (Parslee-ai/car#878 rides on the same attribution rule as #897)"
4264 );
4265 }
4266
4267 #[cfg(target_os = "macos")]
4271 #[test]
4272 fn a_helper_still_blocked_past_the_threshold_is_attributed_the_dialog() {
4273 let mut command = std::process::Command::new("/bin/sh");
4277 command.args(["-c", "sleep 1"]);
4278 let notices = std::sync::atomic::AtomicUsize::new(0);
4279 let run = bounded_command_output_with(
4280 &mut command,
4281 SECURITY_CLI_TIMEOUT,
4282 || true,
4283 || {
4284 notices.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4285 },
4286 )
4287 .unwrap();
4288 assert!(run.output.status.success(), "the child must exit naturally");
4289 assert!(
4290 run.prompted,
4291 "a call still running past PROMPT_EVIDENCE_MIN with a dialog up is \
4292 the call that dialog belongs to"
4293 );
4294 assert_eq!(
4303 notices.load(std::sync::atomic::Ordering::Relaxed),
4304 1,
4305 "a blocked read must explain itself exactly once, promptly"
4306 );
4307 }
4308
4309 #[cfg(target_os = "macos")]
4312 #[test]
4313 fn the_prompt_notice_names_the_wait_and_both_remedies() {
4314 let notice = keychain_prompt_notice("car/parslee_access_token");
4315 assert!(
4316 notice.contains("car/parslee_access_token"),
4317 "must name WHICH item is being asked for — the operator who walked away \
4318 and came back to a stack of prompts cannot read the dialog after the \
4319 fact, and the log is the only record (Parslee-ai/car#897): {notice}"
4320 );
4321 assert!(
4322 notice.contains(&SECURITY_CLI_INTERACTIVE_TIMEOUT.as_secs().to_string()),
4323 "must state how long CAR will wait, or it reads as an indefinite hang: {notice}"
4324 );
4325 assert!(
4326 notice.contains("Always Allow"),
4327 "must name the one click that also prevents the NEXT prompt: {notice}"
4328 );
4329 assert!(
4330 notice.contains("Keychain Access"),
4331 "must name the remedy for someone who already dismissed the dialog: {notice}"
4332 );
4333 assert!(
4334 notice.contains("not hung"),
4335 "the reported failure was reading the silence as a hang and killing it: {notice}"
4336 );
4337 }
4338
4339 #[cfg(target_os = "macos")]
4342 #[test]
4343 fn describe_item_names_the_keychain_item_from_the_argv() {
4344 assert_eq!(
4345 describe_item(&["find-generic-password", "-s", "car", "-a", "token", "-w"]),
4346 "car/token"
4347 );
4348 assert_eq!(
4349 describe_item(&["delete-generic-password", "-s", "car"]),
4350 "car"
4351 );
4352 assert_eq!(
4353 describe_item(&["find-generic-password", "-a", "token"]),
4354 "token"
4355 );
4356 assert_eq!(describe_item(&["unlock-keychain"]), "unlock-keychain");
4358 assert_eq!(describe_item(&[]), "security");
4359 assert_eq!(
4361 describe_item(&["find-generic-password", "-s"]),
4362 "find-generic-password"
4363 );
4364 }
4365
4366 #[cfg(target_os = "macos")]
4367 #[test]
4368 fn mac_delete_uses_security_cli_and_maps_results() {
4369 let r = SecretRef::new("svc", "key");
4370 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4371
4372 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4373 assert_eq!(
4374 cli.calls(),
4375 vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
4376 );
4377
4378 let cli = FakeSecurityCli::new(vec![security_output(
4379 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4380 b"",
4381 b"The specified item could not be found in the keychain.\n",
4382 )]);
4383 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4384
4385 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4386 let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
4387 assert_access_denied_contains(err, "auth denied");
4388 }
4389}