1#[cfg(not(target_os = "macos"))]
64use keyring::Entry;
65use serde::{Deserialize, Serialize};
66use thiserror::Error;
67
68pub mod secure_path;
69pub use secure_path::{
70 atomic_replace_private_file, create_private_file, create_private_file_with_failure_injector,
71 ensure_private_dir, ensure_private_dir_with_failure_injector, harden_owner_only,
72 harden_owner_only_fallible, harden_private_tree, open_private_append,
73 open_private_append_with_failure_injector, open_private_read, open_private_truncate,
74 revalidate_private_file, revalidate_private_path, PrivatePathDurabilityFailureInjector,
75 PrivatePathDurabilityFailurePoint, PrivateTree, PrivateTreePolicy, PrivateTreeReport,
76};
77
78pub const DEFAULT_SERVICE: &str = "car";
90
91pub const OPENROUTER_OAUTH_KEY: &str = "OPENROUTER_OAUTH_API_KEY";
96pub const PARSLEE_ACCESS_TOKEN_KEY: &str = "PARSLEE_ACCESS_TOKEN";
97pub const PARSLEE_REFRESH_TOKEN_KEY: &str = "PARSLEE_REFRESH_TOKEN";
98pub const PARSLEE_EXPIRES_AT_KEY: &str = "PARSLEE_ACCESS_TOKEN_EXPIRES_AT";
99pub const PARSLEE_API_BASE_KEY: &str = "PARSLEE_API_BASE";
100pub const PARSLEE_ACCOUNTS_KEY: &str = "PARSLEE_ACCOUNTS";
101pub const PARSLEE_TOKENS_PREFIX: &str = "PARSLEE_TOKENS_";
102pub const PARSLEE_AUTH_GENERATION_KEY: &str = "PARSLEE_AUTH_GENERATION";
103pub const PARSLEE_AUTH_COMPLETION_KEY: &str = "PARSLEE_AUTH_COMPLETION";
104pub const PARSLEE_ACTIVE_ACCOUNT_ID_KEY: &str = "PARSLEE_ACTIVE_ACCOUNT_ID";
105pub const PARSLEE_AUTH_STATE_V2_KEY: &str = "PARSLEE_AUTH_STATE_V2";
106
107fn daemon_secret_reader_slot(
112) -> &'static std::sync::RwLock<Option<car_daemon_client::proxy::BlockingDaemonSecretReader>> {
113 static READER: std::sync::OnceLock<
114 std::sync::RwLock<Option<car_daemon_client::proxy::BlockingDaemonSecretReader>>,
115 > = std::sync::OnceLock::new();
116 READER.get_or_init(|| std::sync::RwLock::new(None))
117}
118
119fn operator_daemon_secret_reader_slot(
120) -> &'static std::sync::RwLock<Option<car_daemon_client::proxy::BlockingDaemonSecretReader>> {
121 static READER: std::sync::OnceLock<
122 std::sync::RwLock<Option<car_daemon_client::proxy::BlockingDaemonSecretReader>>,
123 > = std::sync::OnceLock::new();
124 READER.get_or_init(|| std::sync::RwLock::new(None))
125}
126
127pub fn install_daemon_secret_reader(
131 client: std::sync::Arc<car_daemon_client::proxy::DaemonClient>,
132) -> Result<(), String> {
133 let reader = car_daemon_client::proxy::BlockingDaemonSecretReader::new(client)?;
134 *daemon_secret_reader_slot()
135 .write()
136 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reader);
137 Ok(())
138}
139
140pub fn install_operator_daemon_secret_reader(
145 client: std::sync::Arc<car_daemon_client::proxy::DaemonClient>,
146) -> Result<(), String> {
147 if !client.is_local_operator_client() {
148 return Err(
149 "operator secret broker requires a loopback client authenticated with the owner-only host token"
150 .to_string(),
151 );
152 }
153 let reader = car_daemon_client::proxy::BlockingDaemonSecretReader::new(client)?;
154 *operator_daemon_secret_reader_slot()
155 .write()
156 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reader);
157 Ok(())
158}
159
160pub fn read_via_operator_broker_or_keychain(r: &SecretRef) -> Result<String, SecretError> {
166 let reader = operator_daemon_secret_reader_slot()
167 .read()
168 .unwrap_or_else(|poisoned| poisoned.into_inner())
169 .clone();
170 let Some(reader) = reader else {
171 return SecretStore::new().get(r);
172 };
173 match reader.get_for_operator(Some(&r.service), &r.key) {
174 Ok(value) => Ok(value),
175 Err(car_daemon_client::proxy::SecretBrokerReadError::NotFound { service, key }) => {
176 Err(SecretError::NotFound { service, key })
177 }
178 Err(car_daemon_client::proxy::SecretBrokerReadError::Refused(message)) => {
179 Err(SecretError::AccessDenied { message })
180 }
181 Err(car_daemon_client::proxy::SecretBrokerReadError::Unavailable(error)) => {
182 tracing::debug!(error = %error, "local daemon secret broker unavailable; using direct credential store");
183 SecretStore::new().get(r)
184 }
185 }
186}
187
188pub fn status_via_operator_broker_or_keychain(r: &SecretRef) -> Result<SecretStatus, SecretError> {
192 let reader = operator_daemon_secret_reader_slot()
193 .read()
194 .unwrap_or_else(|poisoned| poisoned.into_inner())
195 .clone();
196 let Some(reader) = reader else {
197 return SecretStore::new().status(r);
198 };
199 match reader.get_for_operator(Some(&r.service), &r.key) {
200 Ok(_) => Ok(SecretStatus {
201 service: r.service.clone(),
202 key: r.key.clone(),
203 exists: true,
204 }),
205 Err(car_daemon_client::proxy::SecretBrokerReadError::NotFound { .. }) => Ok(SecretStatus {
206 service: r.service.clone(),
207 key: r.key.clone(),
208 exists: false,
209 }),
210 Err(car_daemon_client::proxy::SecretBrokerReadError::Refused(message)) => {
211 Err(SecretError::AccessDenied { message })
212 }
213 Err(car_daemon_client::proxy::SecretBrokerReadError::Unavailable(error)) => {
214 tracing::debug!(error = %error, "local daemon secret broker unavailable; using direct credential status");
215 SecretStore::new().status(r)
216 }
217 }
218}
219
220fn supervised_agent_credentials_present() -> bool {
221 ["CAR_AGENT_ID", "CAR_AGENT_TOKEN"].iter().all(|name| {
222 std::env::var(name)
223 .ok()
224 .is_some_and(|value| !value.trim().is_empty())
225 })
226}
227
228fn supervised_daemon_secret_reader(
231) -> Result<Option<car_daemon_client::proxy::BlockingDaemonSecretReader>, String> {
232 if !supervised_agent_credentials_present() {
233 return Ok(None);
234 }
235 if let Some(reader) = daemon_secret_reader_slot()
236 .read()
237 .unwrap_or_else(|poisoned| poisoned.into_inner())
238 .clone()
239 {
240 return Ok(Some(reader));
241 }
242 let mut slot = daemon_secret_reader_slot()
243 .write()
244 .unwrap_or_else(|poisoned| poisoned.into_inner());
245 if slot.is_none() {
246 *slot = Some(car_daemon_client::proxy::BlockingDaemonSecretReader::new(
247 car_daemon_client::proxy::DaemonClient::new(),
248 )?);
249 }
250 Ok(slot.clone())
251}
252
253fn is_private_chunk_derivative(key: &str, root: &str) -> bool {
254 key.strip_prefix(root)
255 .is_some_and(|suffix| suffix.starts_with("#chunk"))
256}
257
258pub fn is_daemon_private_secret(service: &str, key: &str) -> bool {
259 service == DEFAULT_SERVICE
260 && (matches!(
261 key,
262 OPENROUTER_OAUTH_KEY
263 | PARSLEE_ACCESS_TOKEN_KEY
264 | PARSLEE_REFRESH_TOKEN_KEY
265 | PARSLEE_EXPIRES_AT_KEY
266 | PARSLEE_API_BASE_KEY
267 | PARSLEE_ACCOUNTS_KEY
268 | PARSLEE_AUTH_GENERATION_KEY
269 | PARSLEE_AUTH_COMPLETION_KEY
270 | PARSLEE_ACTIVE_ACCOUNT_ID_KEY
271 | PARSLEE_AUTH_STATE_V2_KEY
272 ) || key.starts_with(PARSLEE_TOKENS_PREFIX)
273 || [
274 OPENROUTER_OAUTH_KEY,
275 PARSLEE_ACCESS_TOKEN_KEY,
276 PARSLEE_REFRESH_TOKEN_KEY,
277 PARSLEE_EXPIRES_AT_KEY,
278 PARSLEE_API_BASE_KEY,
279 PARSLEE_ACCOUNTS_KEY,
280 PARSLEE_AUTH_GENERATION_KEY,
281 PARSLEE_AUTH_COMPLETION_KEY,
282 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
283 PARSLEE_AUTH_STATE_V2_KEY,
284 ]
285 .iter()
286 .any(|root| is_private_chunk_derivative(key, root)))
287}
288
289pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
318 if let Ok(v) = std::env::var(env_var) {
319 if !v.is_empty() {
320 return Some(v);
321 }
322 }
323 let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
324 match supervised_daemon_secret_reader() {
325 Ok(Some(reader)) => match reader.get(Some(DEFAULT_SERVICE), env_var) {
326 Ok(value) if !value.is_empty() => {
327 tracing::debug!(env_var = %env_var, "resolved API key through daemon secret broker");
328 return Some(value);
329 }
330 Ok(_) => return None,
331 Err(car_daemon_client::proxy::SecretBrokerReadError::NotFound { .. }) => return None,
332 Err(car_daemon_client::proxy::SecretBrokerReadError::Refused(error)) => {
333 tracing::warn!(env_var = %env_var, error = %error, "daemon secret broker refused credential read");
336 return None;
337 }
338 Err(car_daemon_client::proxy::SecretBrokerReadError::Unavailable(error)) => {
339 tracing::warn!(env_var = %env_var, error = %error, "daemon secret broker unavailable; refusing child-local credential read");
343 return None;
344 }
345 },
346 Ok(None) => {}
347 Err(error) => {
348 tracing::warn!(env_var = %env_var, error = %error, "daemon secret broker could not start; refusing child-local credential read");
349 return None;
350 }
351 }
352 match read_via_operator_broker_or_keychain(&secret_ref) {
353 Ok(v) if !v.is_empty() => {
354 tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
355 Some(v)
356 }
357 Ok(_) => None, Err(SecretError::NotFound { .. }) => None,
359 Err(e) => {
360 tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
361 None
362 }
363 }
364}
365
366#[derive(Debug, Error)]
368pub enum SecretError {
369 #[error("secret store unavailable: {0}")]
372 Unavailable(String),
373
374 #[error("no entry for service={service:?} key={key:?}")]
376 NotFound { service: String, key: String },
377
378 #[error("secret store access denied: {message}")]
380 AccessDenied { message: String },
381
382 #[error("secret store access cancelled: {message}")]
384 UserCancelled { message: String },
385
386 #[error("secret store helper timed out during {operation}")]
389 HelperTimedOut { operation: String },
390
391 #[error("secret store error: {0}")]
394 Backend(String),
395
396 #[error("stored value is not valid JSON: {0}")]
398 InvalidJson(String),
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct SecretStatus {
404 pub service: String,
405 pub key: String,
406 pub exists: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct AvailabilityCheck {
416 pub available: bool,
417 #[serde(skip_serializing_if = "Option::is_none")]
418 pub reason: Option<String>,
419}
420
421#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
426pub struct SecretStoreActivity {
427 pub get_attempts: u64,
428 pub status_attempts: u64,
429 pub availability_attempts: u64,
430 pub write_attempts: u64,
431 pub delete_attempts: u64,
432}
433
434static GET_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
435static STATUS_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
436static AVAILABILITY_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
437static WRITE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
438static DELETE_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
439
440pub fn secret_store_activity() -> SecretStoreActivity {
442 use std::sync::atomic::Ordering;
443
444 SecretStoreActivity {
445 get_attempts: GET_ATTEMPTS.load(Ordering::Relaxed),
446 status_attempts: STATUS_ATTEMPTS.load(Ordering::Relaxed),
447 availability_attempts: AVAILABILITY_ATTEMPTS.load(Ordering::Relaxed),
448 write_attempts: WRITE_ATTEMPTS.load(Ordering::Relaxed),
449 delete_attempts: DELETE_ATTEMPTS.load(Ordering::Relaxed),
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
455pub struct SecretRef {
456 pub service: String,
457 pub key: String,
458}
459
460impl SecretRef {
461 pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
462 Self {
463 service: service.into(),
464 key: key.into(),
465 }
466 }
467
468 pub fn with_default_service(key: impl Into<String>) -> Self {
469 Self {
470 service: DEFAULT_SERVICE.to_string(),
471 key: key.into(),
472 }
473 }
474}
475
476#[derive(Debug, Default, Clone, Copy)]
482pub struct SecretStore;
483
484impl SecretStore {
485 pub fn new() -> Self {
486 Self
487 }
488
489 pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
506 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
507 platform_put(self, r, value)
508 }
509
510 pub fn publish(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
527 WRITE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
528 platform_publish(self, r, value)
529 }
530
531 pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
533 let s = serde_json::to_string(value)
534 .map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
535 self.put(r, &s)
536 }
537
538 pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
546 GET_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
547 platform_get(self, r)
548 }
549
550 pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
552 let raw = self.get(r)?;
553 serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
554 }
555
556 pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
563 DELETE_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
564 platform_delete(self, r)
565 }
566
567 pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
572 STATUS_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
573 platform_status(self, r)
574 }
575
576 const PROBE_SERVICE: &'static str = "car-internal";
579 const PROBE_KEY: &'static str = "__availability_probe__";
580 #[cfg(target_os = "macos")]
581 const PROBE_VALUE: &'static str = "car-availability-probe";
582
583 pub fn is_available(&self) -> bool {
600 self.availability().available
601 }
602
603 pub fn availability(&self) -> AvailabilityCheck {
609 AVAILABILITY_ATTEMPTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
610 if file_backend_dir().is_some() {
616 return AvailabilityCheck {
617 available: true,
618 reason: None,
619 };
620 }
621 platform_availability(self)
622 }
623
624 #[cfg(not(target_os = "macos"))]
636 fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
637 Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
638 }
639}
640
641fn file_backend_dir() -> Option<std::path::PathBuf> {
677 if !cfg!(debug_assertions) {
680 return None;
681 }
682 match std::env::var_os("CAR_SECRETS_FILE_DIR") {
683 Some(d) if !d.is_empty() => {
684 static WARNED: std::sync::Once = std::sync::Once::new();
687 WARNED.call_once(|| {
688 tracing::warn!(
689 "CAR_SECRETS_FILE_DIR set — secrets are PLAINTEXT ON DISK; \
690 test-only, never production"
691 );
692 });
693 Some(std::path::PathBuf::from(d))
694 }
695 _ => None,
696 }
697}
698
699fn file_backend_path(dir: &std::path::Path, r: &SecretRef) -> std::path::PathBuf {
700 let sanitize = |s: &str| s.replace(['/', '\\', '.'], "_");
702 dir.join(format!("{}.{}", sanitize(&r.service), sanitize(&r.key)))
703}
704
705fn file_backend_put(dir: &std::path::Path, r: &SecretRef, value: &str) -> Result<(), SecretError> {
706 std::fs::create_dir_all(dir)
707 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
708 std::fs::write(file_backend_path(dir, r), value)
709 .map_err(|e| SecretError::Backend(format!("file backend write: {e}")))
710}
711
712fn file_backend_publish(
713 dir: &std::path::Path,
714 r: &SecretRef,
715 value: &str,
716) -> Result<(), SecretError> {
717 use std::io::Write;
718
719 std::fs::create_dir_all(dir)
720 .map_err(|e| SecretError::Backend(format!("file backend mkdir: {e}")))?;
721 let destination = file_backend_path(dir, r);
722 let nonce = publication_nonce();
723 let staging = destination.with_extension(format!("stage-{nonce}"));
724 let mut options = std::fs::OpenOptions::new();
725 options.create_new(true).write(true);
726 #[cfg(unix)]
727 {
728 use std::os::unix::fs::OpenOptionsExt;
729 options.mode(0o600);
730 }
731 let mut file = options
732 .open(&staging)
733 .map_err(|e| SecretError::Backend(format!("file backend stage: {e}")))?;
734 file.write_all(value.as_bytes())
735 .and_then(|_| file.sync_all())
736 .map_err(|e| SecretError::Backend(format!("file backend stage write: {e}")))?;
737 drop(file);
738 if let Err(error) = std::fs::rename(&staging, &destination) {
739 let _ = std::fs::remove_file(&staging);
740 return Err(SecretError::Backend(format!(
741 "file backend publish rename: {error}"
742 )));
743 }
744 Ok(())
745}
746
747fn file_backend_entry_is_merely_absent(dir: &std::path::Path) -> bool {
774 match std::fs::metadata(dir) {
775 Ok(metadata) => metadata.is_dir(),
776 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
777 for ancestor in dir.ancestors().skip(1) {
782 match std::fs::metadata(ancestor) {
783 Ok(metadata) => return metadata.is_dir(),
784 Err(ancestor_error)
785 if ancestor_error.kind() == std::io::ErrorKind::NotFound => {}
786 Err(_) => return false,
787 }
788 }
789 false
790 }
791 Err(_) => false,
792 }
793}
794
795fn file_backend_get(dir: &std::path::Path, r: &SecretRef) -> Result<String, SecretError> {
796 match std::fs::read_to_string(file_backend_path(dir, r)) {
797 Ok(v) => Ok(v),
798 Err(e)
799 if e.kind() == std::io::ErrorKind::NotFound
800 && file_backend_entry_is_merely_absent(dir) =>
801 {
802 Err(SecretError::NotFound {
803 service: r.service.clone(),
804 key: r.key.clone(),
805 })
806 }
807 Err(e) => Err(SecretError::Backend(format!("file backend read: {e}"))),
808 }
809}
810
811fn file_backend_delete(dir: &std::path::Path, r: &SecretRef) -> Result<(), SecretError> {
812 match std::fs::remove_file(file_backend_path(dir, r)) {
813 Ok(()) => Ok(()),
814 Err(e)
815 if e.kind() == std::io::ErrorKind::NotFound
816 && file_backend_entry_is_merely_absent(dir) =>
817 {
818 Ok(())
819 }
820 Err(e) => Err(SecretError::Backend(format!("file backend delete: {e}"))),
821 }
822}
823
824fn file_backend_status(dir: &std::path::Path, r: &SecretRef) -> SecretStatus {
825 SecretStatus {
826 service: r.service.clone(),
827 key: r.key.clone(),
828 exists: file_backend_path(dir, r).exists(),
832 }
833}
834
835#[cfg(target_os = "macos")]
836fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
837 if let Some(dir) = file_backend_dir() {
838 return file_backend_put(&dir, r, value);
839 }
840 mac_put_via_security_cli(&r.service, &r.key, value)
841}
842
843#[cfg(target_os = "macos")]
844fn platform_publish(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
845 if let Some(dir) = file_backend_dir() {
846 return file_backend_publish(&dir, r, value);
847 }
848 mac_publish_via_security_cli(&r.service, &r.key, value)
849}
850
851#[cfg(any(not(target_os = "macos"), test))]
868const CHUNK_SENTINEL: &str = "__car_secrets_chunked_v1__:";
869#[cfg(any(target_os = "windows", test))]
870const CHUNK_SENTINEL_V2: &str = "__car_secrets_chunked_v2__:";
871#[cfg(any(target_os = "windows", test))]
872const CHUNK_SENTINEL_V3: &str = "__car_secrets_chunked_v3__:";
873#[cfg(any(target_os = "windows", test))]
874const CHUNK_VALUE_V3: &str = "__car_secrets_chunk_v3__:";
875#[cfg(any(not(target_os = "macos"), test))]
878const CHUNK_THRESHOLD_UTF16: usize = 2000;
879#[cfg(any(not(target_os = "macos"), test))]
881const CHUNK_CHARS: usize = 1000;
882#[cfg(any(target_os = "windows", test))]
886const WINDOWS_MAX_CHUNKS: usize = 1024;
887#[cfg(any(target_os = "windows", test))]
888const WINDOWS_READ_ATTEMPTS: usize = 4;
889
890#[cfg(not(target_os = "macos"))]
892fn chunk_ref(r: &SecretRef, i: usize) -> SecretRef {
893 SecretRef::new(r.service.clone(), format!("{}#chunk{}", r.key, i))
894}
895
896#[cfg(target_os = "windows")]
897fn chunk_v2_ref(r: &SecretRef, nonce: &str, i: usize) -> SecretRef {
898 SecretRef::new(r.service.clone(), format!("{}#chunkv2#{nonce}#{i}", r.key))
899}
900
901#[cfg(target_os = "windows")]
902fn chunk_v3_ref(r: &SecretRef, generation: ChunkGeneration, i: usize) -> SecretRef {
903 SecretRef::new(
904 r.service.clone(),
905 format!("{}#chunkv3#{}#{i}", r.key, generation.label()),
906 )
907}
908
909#[cfg(target_os = "windows")]
910fn chunk_v3_manifest_ref(r: &SecretRef, generation: ChunkGeneration) -> SecretRef {
911 SecretRef::new(
912 r.service.clone(),
913 format!("{}#chunkv3#{}#manifest", r.key, generation.label()),
914 )
915}
916
917#[cfg(target_os = "windows")]
918fn chunk_v3_retired_v2_ref(r: &SecretRef) -> SecretRef {
919 SecretRef::new(r.service.clone(), format!("{}#chunkv3#retired-v2", r.key))
920}
921
922#[cfg(any(not(target_os = "macos"), test))]
924fn split_on_chars(s: &str, n: usize) -> Vec<String> {
925 let mut out = Vec::new();
926 let mut cur = String::new();
927 let mut count = 0usize;
928 for ch in s.chars() {
929 cur.push(ch);
930 count += 1;
931 if count == n {
932 out.push(std::mem::take(&mut cur));
933 count = 0;
934 }
935 }
936 if !cur.is_empty() {
937 out.push(cur);
938 }
939 out
940}
941
942fn publication_nonce() -> String {
943 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
944 let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
945 let nanos = std::time::SystemTime::now()
946 .duration_since(std::time::UNIX_EPOCH)
947 .map(|duration| duration.as_nanos())
948 .unwrap_or_default();
949 format!("{:x}-{:x}-{:x}", std::process::id(), nanos, sequence)
950}
951
952#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
953#[cfg(any(target_os = "windows", test))]
954enum ChunkGeneration {
955 A,
956 B,
957}
958
959#[cfg(any(target_os = "windows", test))]
960impl ChunkGeneration {
961 fn label(self) -> &'static str {
962 match self {
963 Self::A => "a",
964 Self::B => "b",
965 }
966 }
967
968 fn inactive(self) -> Self {
969 match self {
970 Self::A => Self::B,
971 Self::B => Self::A,
972 }
973 }
974}
975
976#[derive(Debug, Clone, PartialEq, Eq)]
977#[cfg(any(target_os = "windows", test))]
978struct ChunkPublicationPlan {
979 generation: ChunkGeneration,
980 revision: String,
981 chunks: Vec<String>,
982 root: String,
983}
984
985#[cfg(any(target_os = "windows", test))]
986fn chunk_publication_plan(
987 value: &str,
988 generation: ChunkGeneration,
989 revision: &str,
990) -> Result<ChunkPublicationPlan, SecretError> {
991 if revision.is_empty() || revision.contains(':') {
992 return Err(SecretError::Backend(
993 "invalid Windows credential publication revision".to_string(),
994 ));
995 }
996 let mut chunks = split_on_chars(value, CHUNK_CHARS);
997 if chunks.is_empty() {
998 chunks.push(String::new());
999 }
1000 if chunks.len() > WINDOWS_MAX_CHUNKS {
1001 return Err(SecretError::Backend(format!(
1002 "Windows credential publication requires {} chunks; maximum is {WINDOWS_MAX_CHUNKS}",
1003 chunks.len()
1004 )));
1005 }
1006 Ok(ChunkPublicationPlan {
1007 generation,
1008 revision: revision.to_string(),
1009 root: format!(
1010 "{CHUNK_SENTINEL_V3}{}:{revision}:{}",
1011 generation.label(),
1012 chunks.len()
1013 ),
1014 chunks,
1015 })
1016}
1017
1018#[cfg(any(target_os = "windows", test))]
1019fn encode_v3_chunk(revision: &str, value: &str) -> String {
1020 format!("{CHUNK_VALUE_V3}{revision}:{value}")
1021}
1022
1023#[cfg(any(target_os = "windows", test))]
1024fn decode_v3_chunk<'a>(raw: &'a str, revision: &str) -> Result<&'a str, SecretError> {
1025 let payload = raw.strip_prefix(CHUNK_VALUE_V3).ok_or_else(|| {
1026 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
1027 })?;
1028 let (stored_revision, value) = payload.split_once(':').ok_or_else(|| {
1029 SecretError::Backend("invalid Windows v3 credential chunk metadata".to_string())
1030 })?;
1031 if stored_revision != revision {
1032 return Err(SecretError::Backend(
1033 "Windows credential chunk revision changed during read".to_string(),
1034 ));
1035 }
1036 Ok(value)
1037}
1038
1039#[cfg(any(target_os = "windows", test))]
1040fn parse_v2_sentinel(raw: &str) -> Option<(&str, usize)> {
1041 let payload = raw.strip_prefix(CHUNK_SENTINEL_V2)?;
1042 let (nonce, count) = payload.rsplit_once(':')?;
1043 let count = count.parse::<usize>().ok()?;
1044 if nonce.is_empty() || count == 0 || count > WINDOWS_MAX_CHUNKS {
1045 return None;
1046 }
1047 Some((nonce, count))
1048}
1049
1050#[derive(Debug, Clone, PartialEq, Eq)]
1051#[cfg(any(target_os = "windows", test))]
1052enum WindowsRootLayout {
1053 Inline,
1054 LegacyV1 {
1055 count: usize,
1056 },
1057 LegacyV2 {
1058 nonce: String,
1059 count: usize,
1060 },
1061 V3 {
1062 generation: ChunkGeneration,
1063 revision: String,
1064 count: usize,
1065 },
1066}
1067
1068#[cfg(any(target_os = "windows", test))]
1069fn windows_root_layout(raw: &str) -> Result<WindowsRootLayout, SecretError> {
1070 if let Some(payload) = raw.strip_prefix(CHUNK_SENTINEL_V3) {
1071 let (publication, count) = payload.rsplit_once(':').ok_or_else(|| {
1072 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
1073 })?;
1074 let (generation, revision) = publication.split_once(':').ok_or_else(|| {
1075 SecretError::Backend("invalid Windows v3 credential root metadata".to_string())
1076 })?;
1077 let generation = match generation {
1078 "a" => ChunkGeneration::A,
1079 "b" => ChunkGeneration::B,
1080 _ => {
1081 return Err(SecretError::Backend(
1082 "invalid Windows v3 credential generation".to_string(),
1083 ))
1084 }
1085 };
1086 if revision.is_empty() {
1087 return Err(SecretError::Backend(
1088 "invalid Windows v3 credential publication revision".to_string(),
1089 ));
1090 }
1091 let count = count
1092 .parse::<usize>()
1093 .ok()
1094 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS);
1095 return count
1096 .map(|count| WindowsRootLayout::V3 {
1097 generation,
1098 revision: revision.to_string(),
1099 count,
1100 })
1101 .ok_or_else(|| {
1102 SecretError::Backend("invalid Windows v3 credential chunk count".to_string())
1103 });
1104 }
1105
1106 if raw.starts_with(CHUNK_SENTINEL_V2) {
1107 return parse_v2_sentinel(raw)
1108 .map(|(nonce, count)| WindowsRootLayout::LegacyV2 {
1109 nonce: nonce.to_string(),
1110 count,
1111 })
1112 .ok_or_else(|| {
1113 SecretError::Backend("invalid Windows v2 credential root metadata".to_string())
1114 });
1115 }
1116
1117 if let Some(count) = raw.strip_prefix(CHUNK_SENTINEL) {
1118 return count
1119 .parse::<usize>()
1120 .ok()
1121 .filter(|count| *count > 0 && *count <= WINDOWS_MAX_CHUNKS)
1122 .map(|count| WindowsRootLayout::LegacyV1 { count })
1123 .ok_or_else(|| {
1124 SecretError::Backend("invalid Windows v1 credential chunk count".to_string())
1125 });
1126 }
1127
1128 Ok(WindowsRootLayout::Inline)
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1132#[cfg(any(target_os = "windows", test))]
1133enum WindowsCredentialSlot {
1134 Root,
1135 LegacyV1Chunk(usize),
1136 LegacyV2Chunk {
1137 nonce: String,
1138 index: usize,
1139 },
1140 V3Chunk {
1141 generation: ChunkGeneration,
1142 index: usize,
1143 },
1144 V3Manifest(ChunkGeneration),
1145 RetiredV2Manifest,
1146}
1147
1148#[cfg(any(target_os = "windows", test))]
1149trait WindowsCredentialBackend {
1150 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError>;
1151 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError>;
1152 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError>;
1153}
1154
1155#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1156#[cfg(any(target_os = "windows", test))]
1157struct WindowsCleanupReport {
1158 failures: usize,
1159}
1160
1161#[cfg(any(target_os = "windows", test))]
1162fn cleanup_windows_slot(
1163 backend: &mut impl WindowsCredentialBackend,
1164 slot: WindowsCredentialSlot,
1165 report: &mut WindowsCleanupReport,
1166) {
1167 if backend.delete(&slot).is_err() {
1168 report.failures += 1;
1169 }
1170}
1171
1172#[cfg(any(target_os = "windows", test))]
1173fn read_generation_manifest(
1174 backend: &mut impl WindowsCredentialBackend,
1175 generation: ChunkGeneration,
1176) -> Result<usize, SecretError> {
1177 let Some(raw) = backend.read(&WindowsCredentialSlot::V3Manifest(generation))? else {
1178 return Ok(0);
1179 };
1180 raw.parse::<usize>()
1181 .ok()
1182 .filter(|count| *count <= WINDOWS_MAX_CHUNKS)
1183 .ok_or_else(|| {
1184 SecretError::Backend("invalid Windows credential generation manifest".to_string())
1185 })
1186}
1187
1188#[cfg(any(target_os = "windows", test))]
1189fn read_retired_v2_manifest(
1190 backend: &mut impl WindowsCredentialBackend,
1191) -> Result<Option<(String, usize)>, SecretError> {
1192 let Some(raw) = backend.read(&WindowsCredentialSlot::RetiredV2Manifest)? else {
1193 return Ok(None);
1194 };
1195 match windows_root_layout(&raw)? {
1196 WindowsRootLayout::LegacyV2 { nonce, count } => Ok(Some((nonce, count))),
1197 _ => Err(SecretError::Backend(
1198 "invalid retired Windows v2 credential manifest".to_string(),
1199 )),
1200 }
1201}
1202
1203#[cfg(any(target_os = "windows", test))]
1204fn cleanup_retired_v2(
1205 backend: &mut impl WindowsCredentialBackend,
1206 nonce: &str,
1207 count: usize,
1208 report: &mut WindowsCleanupReport,
1209) {
1210 let failures_before = report.failures;
1211 for index in 0..count {
1212 cleanup_windows_slot(
1213 backend,
1214 WindowsCredentialSlot::LegacyV2Chunk {
1215 nonce: nonce.to_string(),
1216 index,
1217 },
1218 report,
1219 );
1220 }
1221 if report.failures == failures_before {
1224 cleanup_windows_slot(backend, WindowsCredentialSlot::RetiredV2Manifest, report);
1225 }
1226}
1227
1228#[cfg(any(target_os = "windows", test))]
1229fn publish_windows_value(
1230 backend: &mut impl WindowsCredentialBackend,
1231 value: &str,
1232) -> Result<WindowsCleanupReport, SecretError> {
1233 let previous_root = backend.read(&WindowsCredentialSlot::Root)?;
1234 let previous_layout = previous_root
1235 .as_deref()
1236 .map(windows_root_layout)
1237 .transpose()?;
1238 let retired_v2_before = read_retired_v2_manifest(backend)?;
1239 let newly_retired_v2 = match previous_layout.as_ref() {
1240 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1241 let root = previous_root
1242 .as_deref()
1243 .expect("a parsed legacy root came from a present credential");
1244 backend.write(&WindowsCredentialSlot::RetiredV2Manifest, root)?;
1245 Some((nonce.clone(), *count))
1246 }
1247 _ => None,
1248 };
1249 let generation = match previous_layout {
1250 Some(WindowsRootLayout::V3 { generation, .. }) => generation.inactive(),
1251 _ => ChunkGeneration::A,
1252 };
1253 let plan = chunk_publication_plan(value, generation, &publication_nonce())?;
1254
1255 let previous_bound = read_generation_manifest(backend, generation)?;
1259 let high_water = previous_bound.max(plan.chunks.len());
1260 backend.write(
1261 &WindowsCredentialSlot::V3Manifest(generation),
1262 &high_water.to_string(),
1263 )?;
1264
1265 let mut staged = 0;
1266 for (index, chunk) in plan.chunks.iter().enumerate() {
1267 let slot = WindowsCredentialSlot::V3Chunk { generation, index };
1268 if let Err(error) = backend.write(&slot, &encode_v3_chunk(&plan.revision, chunk)) {
1269 let mut ignored_cleanup = WindowsCleanupReport::default();
1270 for staged_index in 0..staged {
1271 cleanup_windows_slot(
1272 backend,
1273 WindowsCredentialSlot::V3Chunk {
1274 generation,
1275 index: staged_index,
1276 },
1277 &mut ignored_cleanup,
1278 );
1279 }
1280 return Err(error);
1281 }
1282 staged += 1;
1283 }
1284
1285 if let Err(error) = backend.write(&WindowsCredentialSlot::Root, &plan.root) {
1288 let mut ignored_cleanup = WindowsCleanupReport::default();
1289 for staged_index in 0..staged {
1290 cleanup_windows_slot(
1291 backend,
1292 WindowsCredentialSlot::V3Chunk {
1293 generation,
1294 index: staged_index,
1295 },
1296 &mut ignored_cleanup,
1297 );
1298 }
1299 return Err(error);
1300 }
1301
1302 let mut cleanup = WindowsCleanupReport::default();
1303 let tail_failures_before = cleanup.failures;
1304 for index in plan.chunks.len()..high_water {
1305 cleanup_windows_slot(
1306 backend,
1307 WindowsCredentialSlot::V3Chunk { generation, index },
1308 &mut cleanup,
1309 );
1310 }
1311 if cleanup.failures == tail_failures_before
1312 && backend
1313 .write(
1314 &WindowsCredentialSlot::V3Manifest(generation),
1315 &plan.chunks.len().to_string(),
1316 )
1317 .is_err()
1318 {
1319 cleanup.failures += 1;
1320 }
1321
1322 if let Some((nonce, count)) = retired_v2_before {
1327 if newly_retired_v2.as_ref() != Some(&(nonce.clone(), count)) {
1328 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1329 }
1330 }
1331
1332 Ok(cleanup)
1333}
1334
1335#[cfg(not(target_os = "macos"))]
1339fn clear_chunks(store: &SecretStore, r: &SecretRef) {
1340 for i in 0..1024 {
1341 let cr = chunk_ref(r, i);
1342 let Ok(entry) = store.entry(&cr) else { break };
1343 match entry.delete_credential() {
1344 Ok(_) => {}
1345 Err(keyring::Error::NoEntry) => break,
1346 Err(_) => break,
1347 }
1348 }
1349}
1350
1351#[cfg(any(target_os = "windows", test))]
1352fn read_windows_value(
1353 backend: &mut impl WindowsCredentialBackend,
1354) -> Result<Option<String>, SecretError> {
1355 for attempt in 0..WINDOWS_READ_ATTEMPTS {
1356 let Some(root) = backend.read(&WindowsCredentialSlot::Root)? else {
1357 return Ok(None);
1358 };
1359 let (slots, expected_revision) = match windows_root_layout(&root)? {
1360 WindowsRootLayout::Inline => return Ok(Some(root)),
1361 WindowsRootLayout::LegacyV1 { count } => (
1362 (0..count)
1363 .map(WindowsCredentialSlot::LegacyV1Chunk)
1364 .collect::<Vec<_>>(),
1365 None,
1366 ),
1367 WindowsRootLayout::LegacyV2 { nonce, count } => (
1368 (0..count)
1369 .map(|index| WindowsCredentialSlot::LegacyV2Chunk {
1370 nonce: nonce.clone(),
1371 index,
1372 })
1373 .collect::<Vec<_>>(),
1374 None,
1375 ),
1376 WindowsRootLayout::V3 {
1377 generation,
1378 revision,
1379 count,
1380 } => (
1381 (0..count)
1382 .map(|index| WindowsCredentialSlot::V3Chunk { generation, index })
1383 .collect::<Vec<_>>(),
1384 Some(revision),
1385 ),
1386 };
1387
1388 let mut value = String::new();
1389 let mut chunk_error = None;
1390 for slot in slots {
1391 match backend.read(&slot) {
1392 Ok(Some(chunk)) => {
1393 if let Some(revision) = expected_revision.as_deref() {
1394 match decode_v3_chunk(&chunk, revision) {
1395 Ok(chunk) => value.push_str(chunk),
1396 Err(error) => {
1397 chunk_error = Some(error);
1398 break;
1399 }
1400 }
1401 } else {
1402 value.push_str(&chunk);
1403 }
1404 }
1405 Ok(None) => {
1406 chunk_error = Some(SecretError::Backend(
1407 "Windows credential publication is incomplete".to_string(),
1408 ));
1409 break;
1410 }
1411 Err(error) => {
1412 chunk_error = Some(error);
1413 break;
1414 }
1415 }
1416 }
1417
1418 let root_after = backend.read(&WindowsCredentialSlot::Root);
1419 if matches!(&root_after, Ok(Some(current)) if current != &root) {
1420 if chunk_error.is_none() {
1421 return Ok(Some(value));
1424 }
1425 if attempt + 1 < WINDOWS_READ_ATTEMPTS {
1426 continue;
1427 }
1428 return Err(SecretError::Backend(
1429 "Windows credential root changed during every read attempt".to_string(),
1430 ));
1431 }
1432 if let Some(error) = chunk_error {
1433 return Err(error);
1434 }
1435 match root_after {
1436 Ok(Some(current)) if current == root => return Ok(Some(value)),
1437 Ok(_) if attempt + 1 < WINDOWS_READ_ATTEMPTS => continue,
1438 Ok(_) => {
1439 return Err(SecretError::Backend(
1440 "Windows credential root changed during every read attempt".to_string(),
1441 ))
1442 }
1443 Err(error) => return Err(error),
1444 }
1445 }
1446 Err(SecretError::Backend(
1447 "Windows credential read retry limit reached".to_string(),
1448 ))
1449}
1450
1451#[cfg(any(target_os = "windows", test))]
1452fn delete_windows_value(
1453 backend: &mut impl WindowsCredentialBackend,
1454) -> Result<WindowsCleanupReport, SecretError> {
1455 let root = backend.read(&WindowsCredentialSlot::Root)?;
1456 let layout = root.as_deref().map(windows_root_layout).transpose()?;
1457 let retired_v2 = read_retired_v2_manifest(backend)?;
1458
1459 let mut generation_bounds = [
1462 (
1463 ChunkGeneration::A,
1464 read_generation_manifest(backend, ChunkGeneration::A)?,
1465 ),
1466 (
1467 ChunkGeneration::B,
1468 read_generation_manifest(backend, ChunkGeneration::B)?,
1469 ),
1470 ];
1471 if let Some(WindowsRootLayout::V3 {
1472 generation, count, ..
1473 }) = layout.as_ref()
1474 {
1475 let (_, bound) = generation_bounds
1476 .iter_mut()
1477 .find(|(candidate, _)| candidate == generation)
1478 .expect("both deterministic generations are present");
1479 *bound = (*bound).max(*count);
1480 }
1481
1482 backend.delete(&WindowsCredentialSlot::Root)?;
1483
1484 let mut cleanup = WindowsCleanupReport::default();
1485 for (generation, bound) in generation_bounds {
1486 let failures_before = cleanup.failures;
1487 for index in 0..bound {
1488 cleanup_windows_slot(
1489 backend,
1490 WindowsCredentialSlot::V3Chunk { generation, index },
1491 &mut cleanup,
1492 );
1493 }
1494 if cleanup.failures == failures_before {
1495 cleanup_windows_slot(
1496 backend,
1497 WindowsCredentialSlot::V3Manifest(generation),
1498 &mut cleanup,
1499 );
1500 }
1501 }
1502 match layout {
1503 Some(WindowsRootLayout::LegacyV1 { count }) => {
1504 for index in 0..count {
1505 cleanup_windows_slot(
1506 backend,
1507 WindowsCredentialSlot::LegacyV1Chunk(index),
1508 &mut cleanup,
1509 );
1510 }
1511 }
1512 Some(WindowsRootLayout::LegacyV2 { nonce, count }) => {
1513 for index in 0..count {
1514 cleanup_windows_slot(
1515 backend,
1516 WindowsCredentialSlot::LegacyV2Chunk {
1517 nonce: nonce.clone(),
1518 index,
1519 },
1520 &mut cleanup,
1521 );
1522 }
1523 }
1524 _ => {}
1525 }
1526 if let Some((nonce, count)) = retired_v2 {
1527 cleanup_retired_v2(backend, &nonce, count, &mut cleanup);
1528 }
1529 Ok(cleanup)
1530}
1531
1532#[cfg(not(target_os = "macos"))]
1533fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1534 if let Some(dir) = file_backend_dir() {
1535 return file_backend_put(&dir, r, value);
1536 }
1537 if cfg!(windows) {
1540 clear_chunks(store, r);
1543 if value.encode_utf16().count() > CHUNK_THRESHOLD_UTF16 {
1544 let parts = split_on_chars(value, CHUNK_CHARS);
1545 for (i, part) in parts.iter().enumerate() {
1546 let cr = chunk_ref(r, i);
1547 store
1548 .entry(&cr)?
1549 .set_password(part)
1550 .map_err(|e| classify(e, "set_password(chunk)"))?;
1551 }
1552 let sentinel = format!("{CHUNK_SENTINEL}{}", parts.len());
1555 return store
1556 .entry(r)?
1557 .set_password(&sentinel)
1558 .map_err(|e| classify(e, "set_password(sentinel)"));
1559 }
1560 }
1561 let entry = store.entry(r)?;
1562 entry
1563 .set_password(value)
1564 .map_err(|e| classify(e, "set_password"))
1565}
1566
1567#[cfg(target_os = "windows")]
1568struct KeyringWindowsBackend<'a> {
1569 store: &'a SecretStore,
1570 root: &'a SecretRef,
1571}
1572
1573#[cfg(target_os = "windows")]
1574impl KeyringWindowsBackend<'_> {
1575 fn secret_ref(&self, slot: &WindowsCredentialSlot) -> SecretRef {
1576 match slot {
1577 WindowsCredentialSlot::Root => self.root.clone(),
1578 WindowsCredentialSlot::LegacyV1Chunk(index) => chunk_ref(self.root, *index),
1579 WindowsCredentialSlot::LegacyV2Chunk { nonce, index } => {
1580 chunk_v2_ref(self.root, nonce, *index)
1581 }
1582 WindowsCredentialSlot::V3Chunk { generation, index } => {
1583 chunk_v3_ref(self.root, *generation, *index)
1584 }
1585 WindowsCredentialSlot::V3Manifest(generation) => {
1586 chunk_v3_manifest_ref(self.root, *generation)
1587 }
1588 WindowsCredentialSlot::RetiredV2Manifest => chunk_v3_retired_v2_ref(self.root),
1589 }
1590 }
1591}
1592
1593#[cfg(target_os = "windows")]
1594impl WindowsCredentialBackend for KeyringWindowsBackend<'_> {
1595 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
1596 match self.store.entry(&self.secret_ref(slot))?.get_password() {
1597 Ok(value) => Ok(Some(value)),
1598 Err(keyring::Error::NoEntry) => Ok(None),
1599 Err(error) => Err(classify(error, "get_password(windows-publish)")),
1600 }
1601 }
1602
1603 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
1604 self.store
1605 .entry(&self.secret_ref(slot))?
1606 .set_password(value)
1607 .map_err(|error| classify(error, "set_password(windows-publish)"))
1608 }
1609
1610 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
1611 match self
1612 .store
1613 .entry(&self.secret_ref(slot))?
1614 .delete_credential()
1615 {
1616 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
1617 Err(error) => Err(classify(error, "delete_credential(windows-publish)")),
1618 }
1619 }
1620}
1621
1622#[cfg(target_os = "windows")]
1623fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1624 if let Some(dir) = file_backend_dir() {
1625 return file_backend_publish(&dir, r, value);
1626 }
1627 let mut backend = KeyringWindowsBackend { store, root: r };
1628 let cleanup = publish_windows_value(&mut backend, value)?;
1629 if cleanup.failures > 0 {
1630 tracing::warn!(
1631 cleanup_failures = cleanup.failures,
1632 "Windows credential publication committed; bounded cleanup deferred"
1633 );
1634 }
1635 Ok(())
1636}
1637
1638#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1639fn platform_publish(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
1640 if let Some(dir) = file_backend_dir() {
1641 return file_backend_publish(&dir, r, value);
1642 }
1643 store
1644 .entry(r)?
1645 .set_password(value)
1646 .map_err(|error| classify(error, "publish_password"))
1647}
1648
1649#[cfg(target_os = "macos")]
1650fn platform_get(_store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1651 if let Some(dir) = file_backend_dir() {
1652 return file_backend_get(&dir, r);
1653 }
1654 mac_get_via_security_cli(r)
1655}
1656
1657#[cfg(target_os = "windows")]
1658fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1659 if let Some(dir) = file_backend_dir() {
1660 return file_backend_get(&dir, r);
1661 }
1662 let mut backend = KeyringWindowsBackend { store, root: r };
1663 match read_windows_value(&mut backend)? {
1664 Some(value) => Ok(value),
1665 None => Err(SecretError::NotFound {
1666 service: r.service.clone(),
1667 key: r.key.clone(),
1668 }),
1669 }
1670}
1671
1672#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1673fn platform_get(store: &SecretStore, r: &SecretRef) -> Result<String, SecretError> {
1674 if let Some(dir) = file_backend_dir() {
1675 return file_backend_get(&dir, r);
1676 }
1677 match store.entry(r)?.get_password() {
1678 Ok(value) => Ok(value),
1679 Err(keyring::Error::NoEntry) => Err(SecretError::NotFound {
1680 service: r.service.clone(),
1681 key: r.key.clone(),
1682 }),
1683 Err(error) => Err(classify(error, "get_password")),
1684 }
1685}
1686
1687#[cfg(target_os = "macos")]
1688fn platform_delete(_store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1689 if let Some(dir) = file_backend_dir() {
1690 return file_backend_delete(&dir, r);
1691 }
1692 mac_delete_via_security_cli(r)
1693}
1694
1695#[cfg(target_os = "windows")]
1696fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1697 if let Some(dir) = file_backend_dir() {
1698 return file_backend_delete(&dir, r);
1699 }
1700 let mut backend = KeyringWindowsBackend { store, root: r };
1701 let cleanup = delete_windows_value(&mut backend)?;
1702 if cleanup.failures > 0 {
1703 tracing::warn!(
1704 cleanup_failures = cleanup.failures,
1705 "Windows credential root deleted; bounded cleanup deferred"
1706 );
1707 }
1708 Ok(())
1709}
1710
1711#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
1712fn platform_delete(store: &SecretStore, r: &SecretRef) -> Result<(), SecretError> {
1713 if let Some(dir) = file_backend_dir() {
1714 return file_backend_delete(&dir, r);
1715 }
1716 match store.entry(r)?.delete_credential() {
1717 Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
1718 Err(error) => Err(classify(error, "delete_credential")),
1719 }
1720}
1721
1722#[cfg(target_os = "macos")]
1723fn platform_status(_store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1724 if let Some(dir) = file_backend_dir() {
1725 return Ok(file_backend_status(&dir, r));
1726 }
1727 mac_status_via_security_cli(r)
1728}
1729
1730#[cfg(not(target_os = "macos"))]
1731fn platform_status(store: &SecretStore, r: &SecretRef) -> Result<SecretStatus, SecretError> {
1732 if let Some(dir) = file_backend_dir() {
1733 return Ok(file_backend_status(&dir, r));
1734 }
1735 let entry = store.entry(r)?;
1736 let exists = match entry.get_password() {
1737 Ok(_) => true,
1738 Err(keyring::Error::NoEntry) => false,
1739 Err(other) => return Err(classify(other, "status")),
1740 };
1741 Ok(SecretStatus {
1742 service: r.service.clone(),
1743 key: r.key.clone(),
1744 exists,
1745 })
1746}
1747
1748#[cfg(target_os = "macos")]
1757fn platform_availability(_store: &SecretStore) -> AvailabilityCheck {
1758 mac_availability_via_security_cli_with(&SystemSecurityCli)
1759}
1760
1761#[cfg(target_os = "macos")]
1762fn mac_availability_via_security_cli_with(cli: &impl SecurityCli) -> AvailabilityCheck {
1763 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1764 let result = mac_exists_via_security_cli_with(&probe, cli).and_then(|_| {
1765 mac_put_via_security_cli_with(&probe.service, &probe.key, SecretStore::PROBE_VALUE, cli)
1766 });
1767 match result {
1768 Ok(()) => AvailabilityCheck {
1769 available: true,
1770 reason: None,
1771 },
1772 Err(error) => AvailabilityCheck {
1773 available: false,
1774 reason: Some(error.to_string()),
1775 },
1776 }
1777}
1778
1779#[cfg(not(target_os = "macos"))]
1780fn platform_availability(store: &SecretStore) -> AvailabilityCheck {
1781 let probe = SecretRef::new(SecretStore::PROBE_SERVICE, SecretStore::PROBE_KEY);
1782 match store.entry(&probe) {
1783 Ok(entry) => match entry.get_password() {
1784 Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
1785 available: true,
1786 reason: None,
1787 },
1788 Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
1789 available: false,
1790 reason: Some(format!("platform failure: {e}")),
1791 },
1792 Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
1793 available: false,
1794 reason: Some(format!("no storage access: {e}")),
1795 },
1796 Err(_) => AvailabilityCheck {
1803 available: true,
1804 reason: None,
1805 },
1806 },
1807 Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
1808 available: false,
1809 reason: Some(reason),
1810 },
1811 Err(other) => AvailabilityCheck {
1812 available: false,
1813 reason: Some(other.to_string()),
1814 },
1815 }
1816}
1817
1818#[cfg(target_os = "macos")]
1829fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
1830 mac_put_via_security_cli_with(service, account, value, &SystemSecurityCli)
1831}
1832
1833#[cfg(target_os = "macos")]
1834fn mac_publish_via_security_cli(
1835 service: &str,
1836 account: &str,
1837 value: &str,
1838) -> Result<(), SecretError> {
1839 mac_publish_via_security_cli_with(service, account, value, &SystemSecurityCli)
1840}
1841
1842#[cfg(target_os = "macos")]
1843fn mac_publish_via_security_cli_with(
1844 service: &str,
1845 account: &str,
1846 value: &str,
1847 cli: &impl SecurityCli,
1848) -> Result<(), SecretError> {
1849 mac_write_via_security_cli(service, account, value, cli)
1850}
1851
1852#[cfg(target_os = "macos")]
1853fn mac_put_via_security_cli_with(
1854 service: &str,
1855 account: &str,
1856 value: &str,
1857 cli: &impl SecurityCli,
1858) -> Result<(), SecretError> {
1859 mac_write_via_security_cli(service, account, value, cli)
1860}
1861
1862#[cfg(target_os = "macos")]
1870fn mac_write_via_security_cli(
1871 service: &str,
1872 account: &str,
1873 value: &str,
1874 cli: &impl SecurityCli,
1875) -> Result<(), SecretError> {
1876 let output = cli.output(&[
1877 "add-generic-password",
1878 "-U", "-A", "-s",
1881 service,
1882 "-a",
1883 account,
1884 "-w",
1885 value,
1886 ])?;
1887 if output.success {
1888 return Ok(());
1889 }
1890 Err(security_cli_backend_error("add-generic-password", output))
1891}
1892
1893#[cfg(target_os = "macos")]
1894const SECURITY_ERR_SEC_ITEM_NOT_FOUND: i32 = 44;
1895
1896#[cfg(target_os = "macos")]
1897#[derive(Debug)]
1898struct SecurityCliOutput {
1899 success: bool,
1900 code: Option<i32>,
1901 stdout: Vec<u8>,
1902 stderr: Vec<u8>,
1903}
1904
1905#[cfg(target_os = "macos")]
1906trait SecurityCli {
1907 fn output(&self, args: &[&str]) -> Result<SecurityCliOutput, SecretError>;
1908}
1909
1910#[cfg(target_os = "macos")]
1911struct SystemSecurityCli;
1912
1913#[cfg(target_os = "macos")]
1914impl SecurityCli for SystemSecurityCli {
1915 fn output(&self, args: &[&str]) -> Result<SecurityCliOutput, SecretError> {
1916 let keychain_path = selected_keychain_path()
1917 .map_err(|error| SecretError::Backend(format!("select macOS keychain: {error}")))?;
1918 let access = MacKeychainAccess::for_security_args(args);
1919 let operation = args.first().copied().unwrap_or("security");
1920 let mut command = std::process::Command::new("/usr/bin/security");
1921 command.args(args);
1922 if let Some(path) = keychain_path.as_ref() {
1923 command.arg(path);
1924 }
1925 security_cli_output_after_preflight(command, operation, || {
1926 mac_keychain_preflight(keychain_path.as_deref(), access)
1927 })
1928 }
1929}
1930
1931#[cfg(target_os = "macos")]
1932const KEYCHAIN_PATH_ENV: &str = "CAR_KEYCHAIN_PATH";
1933
1934#[cfg(target_os = "macos")]
1935const KEYCHAIN_PROOF_ROOT_ENV: &str = "CAR_KEYCHAIN_PROOF_ROOT";
1936
1937#[cfg(target_os = "macos")]
1940fn selected_keychain_path() -> std::io::Result<Option<std::path::PathBuf>> {
1941 let Some(path) = std::env::var_os(KEYCHAIN_PATH_ENV).filter(|value| !value.is_empty()) else {
1942 return Ok(None);
1943 };
1944 let proof_root = std::env::var_os(KEYCHAIN_PROOF_ROOT_ENV)
1945 .filter(|value| !value.is_empty())
1946 .ok_or_else(|| {
1947 std::io::Error::new(
1948 std::io::ErrorKind::InvalidInput,
1949 format!("{KEYCHAIN_PATH_ENV} requires {KEYCHAIN_PROOF_ROOT_ENV}"),
1950 )
1951 })?;
1952 validate_keychain_path(
1953 std::path::Path::new(&path),
1954 std::path::Path::new(&proof_root),
1955 )
1956 .map(Some)
1957}
1958
1959#[cfg(target_os = "macos")]
1960fn validate_keychain_path(
1961 path: &std::path::Path,
1962 proof_root: &std::path::Path,
1963) -> std::io::Result<std::path::PathBuf> {
1964 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1965
1966 if !path.is_absolute() || !proof_root.is_absolute() {
1967 return Err(std::io::Error::new(
1968 std::io::ErrorKind::InvalidInput,
1969 "isolated Keychain path and proof root must be absolute",
1970 ));
1971 }
1972
1973 let expected_uid = current_effective_uid();
1974 let root_metadata = std::fs::symlink_metadata(proof_root)?;
1975 if root_metadata.file_type().is_symlink()
1976 || !root_metadata.is_dir()
1977 || root_metadata.uid() != expected_uid
1978 || root_metadata.permissions().mode() & 0o077 != 0
1979 {
1980 return Err(std::io::Error::new(
1981 std::io::ErrorKind::PermissionDenied,
1982 "Keychain proof root must be an owner-private, non-symlink directory owned by the current user",
1983 ));
1984 }
1985
1986 let path_metadata = std::fs::symlink_metadata(path)?;
1987 if path_metadata.file_type().is_symlink()
1988 || !path_metadata.is_file()
1989 || path_metadata.uid() != expected_uid
1990 || path_metadata.permissions().mode() & 0o077 != 0
1991 {
1992 return Err(std::io::Error::new(
1993 std::io::ErrorKind::PermissionDenied,
1994 "isolated Keychain must be an owner-private, non-symlink regular file owned by the current user",
1995 ));
1996 }
1997
1998 let canonical_root = std::fs::canonicalize(proof_root)?;
1999 let canonical_path = std::fs::canonicalize(path)?;
2000 if !canonical_path.starts_with(&canonical_root) || canonical_path == canonical_root {
2001 return Err(std::io::Error::new(
2002 std::io::ErrorKind::PermissionDenied,
2003 "isolated Keychain must be canonically contained by its proof root",
2004 ));
2005 }
2006 Ok(canonical_path)
2007}
2008
2009#[cfg(target_os = "macos")]
2010fn current_effective_uid() -> u32 {
2011 unsafe extern "C" {
2012 fn geteuid() -> u32;
2013 }
2014 unsafe { geteuid() }
2016}
2017
2018#[cfg(target_os = "macos")]
2019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2020enum MacKeychainAccess {
2021 Read,
2022 Write,
2023}
2024
2025#[cfg(target_os = "macos")]
2026impl MacKeychainAccess {
2027 fn for_security_args(args: &[&str]) -> Self {
2028 match args.first().copied() {
2029 Some("find-generic-password") => Self::Read,
2030 _ => Self::Write,
2031 }
2032 }
2033}
2034
2035#[cfg(target_os = "macos")]
2036const KEYCHAIN_UNLOCKED: u32 = 1;
2037#[cfg(target_os = "macos")]
2038const KEYCHAIN_READABLE: u32 = 2;
2039#[cfg(target_os = "macos")]
2040const KEYCHAIN_WRITABLE: u32 = 4;
2041
2042#[cfg(target_os = "macos")]
2043fn mac_keychain_status_allows(status: u32, access: MacKeychainAccess) -> bool {
2044 let required = KEYCHAIN_UNLOCKED
2045 | KEYCHAIN_READABLE
2046 | match access {
2047 MacKeychainAccess::Read => 0,
2048 MacKeychainAccess::Write => KEYCHAIN_WRITABLE,
2049 };
2050 status & required == required
2051}
2052
2053#[cfg(target_os = "macos")]
2054fn mac_keychain_preflight(
2055 path: Option<&std::path::Path>,
2056 access: MacKeychainAccess,
2057) -> Result<(), SecretError> {
2058 mac_keychain_preflight_status(mac_keychain_status(path)?, access)
2059}
2060
2061#[cfg(target_os = "macos")]
2062fn mac_keychain_preflight_status(
2063 status: u32,
2064 access: MacKeychainAccess,
2065) -> Result<(), SecretError> {
2066 if status & KEYCHAIN_UNLOCKED == 0 {
2067 return Err(SecretError::Unavailable(
2068 "macOS keychain is locked; unlock it before retrying (no security helper was started)"
2069 .to_string(),
2070 ));
2071 }
2072 if !mac_keychain_status_allows(status, access) {
2073 let operation = match access {
2074 MacKeychainAccess::Read => "readable",
2075 MacKeychainAccess::Write => "readable and writable",
2076 };
2077 return Err(SecretError::Unavailable(format!(
2078 "macOS keychain is not {operation} (no security helper was started)"
2079 )));
2080 }
2081 Ok(())
2082}
2083
2084#[cfg(target_os = "macos")]
2085fn mac_keychain_status(path: Option<&std::path::Path>) -> Result<u32, SecretError> {
2086 use std::os::unix::ffi::OsStrExt;
2087
2088 #[link(name = "Security", kind = "framework")]
2089 unsafe extern "C" {
2090 #[link_name = "SecKeychainOpen"]
2091 fn sec_keychain_open(path: *const libc::c_char, keychain: *mut *mut libc::c_void) -> i32;
2092 #[link_name = "SecKeychainGetStatus"]
2093 fn sec_keychain_get_status(keychain: *mut libc::c_void, status: *mut u32) -> i32;
2094 }
2095 #[link(name = "CoreFoundation", kind = "framework")]
2096 unsafe extern "C" {
2097 #[link_name = "CFRelease"]
2098 fn cf_release(value: *const libc::c_void);
2099 }
2100
2101 let mut opened_keychain = std::ptr::null_mut();
2102 if let Some(path) = path {
2103 let path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
2104 SecretError::Backend("selected macOS keychain path contains a NUL byte".to_string())
2105 })?;
2106 let code = unsafe { sec_keychain_open(path.as_ptr(), &mut opened_keychain) };
2109 if code != 0 {
2110 if !opened_keychain.is_null() {
2111 unsafe { cf_release(opened_keychain) };
2113 }
2114 return Err(SecretError::Unavailable(format!(
2115 "macOS keychain status probe could not open the selected keychain: code={code}"
2116 )));
2117 }
2118 }
2119
2120 let mut status = 0_u32;
2121 let code = unsafe { sec_keychain_get_status(opened_keychain, &mut status) };
2127 if !opened_keychain.is_null() {
2128 unsafe { cf_release(opened_keychain) };
2130 }
2131 if code != 0 {
2132 return Err(SecretError::Unavailable(format!(
2133 "macOS keychain status probe failed: code={code}"
2134 )));
2135 }
2136 Ok(status)
2137}
2138
2139#[cfg(target_os = "macos")]
2142struct SecurityCliExecutor {
2143 in_flight: std::sync::Arc<std::sync::atomic::AtomicBool>,
2144 timeout: std::time::Duration,
2145}
2146
2147#[cfg(target_os = "macos")]
2148impl SecurityCliExecutor {
2149 fn new(timeout: std::time::Duration) -> Self {
2150 Self {
2151 in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2152 timeout,
2153 }
2154 }
2155
2156 fn output(
2157 &self,
2158 mut command: std::process::Command,
2159 operation: &str,
2160 preflight: impl FnOnce() -> Result<(), SecretError>,
2161 ) -> Result<SecurityCliOutput, SecretError> {
2162 use std::sync::atomic::Ordering;
2163 preflight()?;
2164 if self
2165 .in_flight
2166 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
2167 .is_err()
2168 {
2169 return Err(SecretError::Unavailable(
2170 "macOS keychain helper is still pending; finish or dismiss its authorization prompt, then retry (no additional helper started)".into(),
2171 ));
2172 }
2173 struct Flight(std::sync::Arc<std::sync::atomic::AtomicBool>);
2176 impl Drop for Flight {
2177 fn drop(&mut self) {
2178 self.0.store(false, Ordering::Release);
2179 }
2180 }
2181 let flight = Flight(self.in_flight.clone());
2182 let operation_owned = operation.to_owned();
2183 let (sender, receiver) = std::sync::mpsc::sync_channel(1);
2184 std::thread::Builder::new()
2185 .name("car-keychain-helper".into())
2186 .spawn(move || {
2187 let flight = flight;
2188 command.stdin(std::process::Stdio::null());
2189 let result = command
2190 .output()
2191 .map(|output| SecurityCliOutput {
2192 success: output.status.success(),
2193 code: output.status.code(),
2194 stdout: output.stdout,
2195 stderr: output.stderr,
2196 })
2197 .map_err(|error| security_cli_spawn_error(&operation_owned, error));
2198 drop(flight);
2201 let _ = sender.send(result);
2202 })
2203 .map_err(|error| security_cli_spawn_error(operation, error))?;
2204 receiver.recv_timeout(self.timeout).map_err(|error| {
2205 SecretError::Unavailable(match error {
2206 std::sync::mpsc::RecvTimeoutError::Timeout =>
2207 "macOS keychain helper is still pending; finish or dismiss its authorization prompt, then retry (helper retained without termination)".into(),
2208 std::sync::mpsc::RecvTimeoutError::Disconnected =>
2209 "macOS keychain helper worker disconnected; retry the credential operation".into(),
2210 })
2211 })?
2212 }
2213}
2214
2215#[cfg(target_os = "macos")]
2218fn security_cli_output_after_preflight(
2219 command: std::process::Command,
2220 operation: &str,
2221 preflight: impl FnOnce() -> Result<(), SecretError>,
2222) -> Result<SecurityCliOutput, SecretError> {
2223 static EXECUTOR: std::sync::OnceLock<SecurityCliExecutor> = std::sync::OnceLock::new();
2224 EXECUTOR
2225 .get_or_init(|| SecurityCliExecutor::new(std::time::Duration::from_secs(15)))
2226 .output(command, operation, preflight)
2227}
2228
2229#[cfg(target_os = "macos")]
2237fn mac_get_via_security_cli(r: &SecretRef) -> Result<String, SecretError> {
2238 mac_get_via_security_cli_with(r, &SystemSecurityCli)
2239}
2240
2241#[cfg(target_os = "macos")]
2250fn mac_get_via_security_cli_with(
2251 r: &SecretRef,
2252 cli: &impl SecurityCli,
2253) -> Result<String, SecretError> {
2254 let output = cli.output(&[
2255 "find-generic-password",
2256 "-s",
2257 &r.service,
2258 "-a",
2259 &r.key,
2260 "-g",
2261 ])?;
2262 if !output.success {
2263 return security_cli_not_found_or_backend("find-generic-password", r, output);
2264 }
2265 mac_parse_security_cli_password(&output)
2266}
2267
2268#[cfg(target_os = "macos")]
2269fn mac_parse_security_cli_password(output: &SecurityCliOutput) -> Result<String, SecretError> {
2270 let line = mac_security_cli_text(&output.stderr, "stderr")?
2271 .lines()
2272 .find(|line| line.starts_with("password:"))
2273 .or_else(|| {
2274 mac_security_cli_text(&output.stdout, "stdout")
2275 .ok()
2276 .and_then(|stdout| stdout.lines().find(|line| line.starts_with("password:")))
2277 })
2278 .ok_or_else(|| {
2279 SecretError::Backend(
2280 "/usr/bin/security find-generic-password -g did not print a password line"
2281 .to_string(),
2282 )
2283 })?;
2284
2285 let payload = line
2286 .strip_prefix("password:")
2287 .expect("password line prefix was checked")
2288 .trim_start();
2289
2290 if payload.is_empty() {
2291 return Ok(String::new());
2292 }
2293
2294 let bytes = if let Some(hex_and_preview) = payload.strip_prefix("0x") {
2295 mac_decode_security_cli_hex_password(hex_and_preview)?
2296 } else {
2297 mac_decode_security_cli_quoted_password(payload)?
2298 };
2299
2300 String::from_utf8(bytes).map_err(|e| {
2301 SecretError::Backend(format!(
2302 "/usr/bin/security find-generic-password password was not valid utf-8: {}",
2303 e
2304 ))
2305 })
2306}
2307
2308#[cfg(target_os = "macos")]
2309fn mac_security_cli_text<'a>(bytes: &'a [u8], stream: &str) -> Result<&'a str, SecretError> {
2310 std::str::from_utf8(bytes).map_err(|e| {
2311 SecretError::Backend(format!(
2312 "/usr/bin/security find-generic-password {stream} was not valid utf-8: {e}"
2313 ))
2314 })
2315}
2316
2317#[cfg(target_os = "macos")]
2318fn mac_decode_security_cli_hex_password(hex_and_preview: &str) -> Result<Vec<u8>, SecretError> {
2319 let hex: String = hex_and_preview
2320 .chars()
2321 .take_while(|c| c.is_ascii_hexdigit())
2322 .collect();
2323 if hex.is_empty() || !hex.len().is_multiple_of(2) {
2324 return Err(SecretError::Backend(format!(
2325 "/usr/bin/security find-generic-password printed invalid password hex: {hex:?}"
2326 )));
2327 }
2328
2329 (0..hex.len())
2330 .step_by(2)
2331 .map(|i| {
2332 u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| {
2333 SecretError::Backend(format!(
2334 "/usr/bin/security find-generic-password printed invalid password hex: {e}"
2335 ))
2336 })
2337 })
2338 .collect()
2339}
2340
2341#[cfg(target_os = "macos")]
2342fn mac_decode_security_cli_quoted_password(payload: &str) -> Result<Vec<u8>, SecretError> {
2343 let quoted = payload.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
2344 match quoted {
2345 Some(value) => Ok(value.as_bytes().to_vec()),
2346 None => Err(SecretError::Backend(
2347 "/usr/bin/security find-generic-password printed an unrecognized password line"
2348 .to_string(),
2349 )),
2350 }
2351}
2352
2353#[cfg(target_os = "macos")]
2354fn mac_status_via_security_cli(r: &SecretRef) -> Result<SecretStatus, SecretError> {
2355 mac_status_via_security_cli_with(r, &SystemSecurityCli)
2356}
2357
2358#[cfg(target_os = "macos")]
2359fn mac_status_via_security_cli_with(
2360 r: &SecretRef,
2361 cli: &impl SecurityCli,
2362) -> Result<SecretStatus, SecretError> {
2363 let exists = mac_exists_via_security_cli_with(r, cli)?;
2364 Ok(SecretStatus {
2365 service: r.service.clone(),
2366 key: r.key.clone(),
2367 exists,
2368 })
2369}
2370
2371#[cfg(target_os = "macos")]
2376fn mac_exists_via_security_cli_with(
2377 r: &SecretRef,
2378 cli: &impl SecurityCli,
2379) -> Result<bool, SecretError> {
2380 let output = cli.output(&["find-generic-password", "-s", &r.service, "-a", &r.key])?;
2381 if output.success {
2382 return Ok(true);
2383 }
2384 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2385 return Ok(false);
2386 }
2387 Err(security_cli_backend_error("find-generic-password", output))
2388}
2389
2390#[cfg(target_os = "macos")]
2391fn mac_delete_via_security_cli(r: &SecretRef) -> Result<(), SecretError> {
2392 mac_delete_via_security_cli_with(r, &SystemSecurityCli)
2393}
2394
2395#[cfg(target_os = "macos")]
2398fn mac_delete_via_security_cli_with(
2399 r: &SecretRef,
2400 cli: &impl SecurityCli,
2401) -> Result<(), SecretError> {
2402 let output = cli.output(&["delete-generic-password", "-s", &r.service, "-a", &r.key])?;
2403 if output.success || output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2404 return Ok(());
2405 }
2406 Err(security_cli_backend_error(
2407 "delete-generic-password",
2408 output,
2409 ))
2410}
2411
2412#[cfg(target_os = "macos")]
2413fn security_cli_not_found_or_backend<T>(
2414 command: &str,
2415 r: &SecretRef,
2416 output: SecurityCliOutput,
2417) -> Result<T, SecretError> {
2418 if output.code == Some(SECURITY_ERR_SEC_ITEM_NOT_FOUND) {
2419 return Err(SecretError::NotFound {
2420 service: r.service.clone(),
2421 key: r.key.clone(),
2422 });
2423 }
2424 Err(security_cli_backend_error(command, output))
2425}
2426
2427#[cfg(target_os = "macos")]
2428fn security_cli_spawn_error(command: &str, e: std::io::Error) -> SecretError {
2429 SecretError::Backend(format!("/usr/bin/security {command} spawn: {e}"))
2430}
2431
2432#[cfg(target_os = "macos")]
2433fn security_cli_backend_error(command: &str, output: SecurityCliOutput) -> SecretError {
2434 let stderr = String::from_utf8_lossy(&output.stderr);
2435 let code = output.code.unwrap_or(-1);
2436 match classify_security_error(code, stderr.trim()) {
2437 SecretError::Backend(_) => SecretError::Backend(format!(
2438 "/usr/bin/security {command} failed: code={code} {}",
2439 stderr.trim()
2440 )),
2441 typed => typed,
2442 }
2443}
2444
2445#[cfg(target_os = "macos")]
2446fn classify_security_error(code: i32, detail: &str) -> SecretError {
2447 let normalized = detail.to_ascii_lowercase();
2448 if code == -128 || (code == 128 && normalized.contains("cancel")) {
2449 return SecretError::UserCancelled {
2450 message: detail.to_string(),
2451 };
2452 }
2453 if code == -25293
2454 || code == 51
2455 || normalized.contains("authorization denied")
2456 || normalized.contains("auth denied")
2457 || normalized.contains("interaction is not allowed")
2458 {
2459 return SecretError::AccessDenied {
2460 message: detail.to_string(),
2461 };
2462 }
2463 SecretError::Backend(format!("macOS security error: code={code} {detail}"))
2464}
2465
2466#[cfg(not(target_os = "macos"))]
2472fn classify(e: keyring::Error, op: &str) -> SecretError {
2473 use keyring::Error as K;
2474 match e {
2475 K::NoEntry => SecretError::NotFound {
2476 service: String::new(),
2477 key: String::new(),
2478 },
2479 K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2480 K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
2481 K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
2482 other => SecretError::Backend(format!("{}: {}", op, other)),
2483 }
2484}
2485
2486#[cfg(test)]
2487mod chunk_tests {
2488 use super::*;
2489 use std::collections::BTreeMap;
2490
2491 #[test]
2492 fn split_on_chars_covers_boundaries() {
2493 assert_eq!(split_on_chars("", 3), Vec::<String>::new());
2494 assert_eq!(split_on_chars("abc", 3), vec!["abc"]);
2495 assert_eq!(split_on_chars("abcd", 3), vec!["abc", "d"]);
2496 assert_eq!(split_on_chars("abcdef", 2), vec!["ab", "cd", "ef"]);
2497 let big: String = "x".repeat(4000);
2499 let joined: String = split_on_chars(&big, CHUNK_CHARS).concat();
2500 assert_eq!(joined, big);
2501 }
2502
2503 #[test]
2504 fn sentinel_round_trips_the_chunk_count() {
2505 let n = split_on_chars(&"y".repeat(3300), CHUNK_CHARS).len();
2506 let sentinel = format!("{CHUNK_SENTINEL}{n}");
2507 let parsed = sentinel
2508 .strip_prefix(CHUNK_SENTINEL)
2509 .and_then(|s| s.parse::<usize>().ok());
2510 assert_eq!(parsed, Some(4)); assert!("eyJhbGciOi.reallongjwt"
2513 .strip_prefix(CHUNK_SENTINEL)
2514 .is_none());
2515 }
2516
2517 #[test]
2518 fn threshold_leaves_small_values_inline() {
2519 assert!("short-api-key".encode_utf16().count() <= CHUNK_THRESHOLD_UTF16);
2522 assert!("z".repeat(2001).encode_utf16().count() > CHUNK_THRESHOLD_UTF16);
2523 }
2524
2525 #[derive(Debug, Clone)]
2526 struct FailureRule {
2527 slot: WindowsCredentialSlot,
2528 matches_to_skip: usize,
2529 }
2530
2531 #[derive(Debug, Clone, Default)]
2532 struct MemoryWindowsBackend {
2533 entries: BTreeMap<WindowsCredentialSlot, String>,
2534 mutation_calls: usize,
2535 crash_after_mutation: Option<usize>,
2536 fail_write: Option<FailureRule>,
2537 fail_delete: Option<FailureRule>,
2538 }
2539
2540 impl MemoryWindowsBackend {
2541 fn after_mutation(&mut self) {
2542 self.mutation_calls += 1;
2543 if self.crash_after_mutation == Some(self.mutation_calls) {
2544 panic!("injected Windows credential process crash");
2545 }
2546 }
2547
2548 fn should_fail(rule: &mut Option<FailureRule>, slot: &WindowsCredentialSlot) -> bool {
2549 let Some(candidate) = rule.as_mut() else {
2550 return false;
2551 };
2552 if &candidate.slot != slot {
2553 return false;
2554 }
2555 if candidate.matches_to_skip > 0 {
2556 candidate.matches_to_skip -= 1;
2557 return false;
2558 }
2559 *rule = None;
2560 true
2561 }
2562
2563 fn reset_faults(&mut self) {
2564 self.mutation_calls = 0;
2565 self.crash_after_mutation = None;
2566 self.fail_write = None;
2567 self.fail_delete = None;
2568 }
2569
2570 fn root(&self) -> String {
2571 self.entries
2572 .get(&WindowsCredentialSlot::Root)
2573 .expect("root credential")
2574 .clone()
2575 }
2576 }
2577
2578 impl WindowsCredentialBackend for MemoryWindowsBackend {
2579 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2580 Ok(self.entries.get(slot).cloned())
2581 }
2582
2583 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2584 if Self::should_fail(&mut self.fail_write, slot) {
2585 return Err(SecretError::Backend(
2586 "injected Windows credential write failure".to_string(),
2587 ));
2588 }
2589 self.entries.insert(slot.clone(), value.to_string());
2590 self.after_mutation();
2591 Ok(())
2592 }
2593
2594 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2595 if Self::should_fail(&mut self.fail_delete, slot) {
2596 return Err(SecretError::Backend(
2597 "injected Windows credential cleanup failure".to_string(),
2598 ));
2599 }
2600 self.entries.remove(slot);
2601 self.after_mutation();
2602 Ok(())
2603 }
2604 }
2605
2606 fn publish(backend: &mut MemoryWindowsBackend, value: &str) -> WindowsCleanupReport {
2607 publish_windows_value(backend, value).expect("publication")
2608 }
2609
2610 fn read(backend: &mut impl WindowsCredentialBackend) -> String {
2611 read_windows_value(backend)
2612 .expect("read succeeds")
2613 .expect("root exists")
2614 }
2615
2616 fn legacy_v2(value: &str, nonce: &str) -> MemoryWindowsBackend {
2617 let mut backend = MemoryWindowsBackend::default();
2618 let chunks = split_on_chars(value, CHUNK_CHARS);
2619 backend.entries.insert(
2620 WindowsCredentialSlot::Root,
2621 format!("{CHUNK_SENTINEL_V2}{nonce}:{}", chunks.len()),
2622 );
2623 for (index, chunk) in chunks.into_iter().enumerate() {
2624 backend.entries.insert(
2625 WindowsCredentialSlot::LegacyV2Chunk {
2626 nonce: nonce.to_string(),
2627 index,
2628 },
2629 chunk,
2630 );
2631 }
2632 backend
2633 }
2634
2635 fn assert_backend_error(error: SecretError, needle: &str) {
2636 match error {
2637 SecretError::Backend(message) => assert!(message.contains(needle), "{message}"),
2638 other => panic!("expected backend error, got {other:?}"),
2639 }
2640 }
2641
2642 #[test]
2643 fn v3_publication_uses_revisioned_dual_generation_roots() {
2644 let value = "v".repeat(3300);
2645 let plan = chunk_publication_plan(&value, ChunkGeneration::B, "revision-7").unwrap();
2646
2647 assert_eq!(plan.generation, ChunkGeneration::B);
2648 assert_eq!(plan.chunks.concat(), value);
2649 assert_eq!(
2650 windows_root_layout(&plan.root).unwrap(),
2651 WindowsRootLayout::V3 {
2652 generation: ChunkGeneration::B,
2653 revision: "revision-7".to_string(),
2654 count: 4,
2655 }
2656 );
2657 assert!(
2658 plan.chunks
2659 .iter()
2660 .all(|chunk| chunk.encode_utf16().count() <= CHUNK_CHARS),
2661 "every staged credential must remain below the platform cap"
2662 );
2663 }
2664
2665 #[test]
2666 fn reader_capturing_old_root_finishes_after_writer_swaps_root() {
2667 let old = "old-".repeat(900);
2668 let new = "new-".repeat(900);
2669 let mut backend = MemoryWindowsBackend::default();
2670 publish(&mut backend, &old);
2671
2672 let mut reader = InterleavingReader::new(backend, vec![new.as_str()]);
2673 assert_eq!(read(&mut reader), old);
2674 assert_eq!(read(&mut reader.inner), new);
2675 }
2676
2677 #[test]
2678 fn reader_detects_generation_aba_and_retries_latest_root() {
2679 let old = "old-".repeat(900);
2680 let middle = "mid-".repeat(1100);
2681 let latest = "latest-".repeat(700);
2682 let mut backend = MemoryWindowsBackend::default();
2683 publish(&mut backend, &old);
2684
2685 let mut reader = InterleavingReader::new(backend, vec![middle.as_str(), latest.as_str()]);
2686 assert_eq!(read(&mut reader), latest);
2687 assert!(reader.root_reads >= 4, "the ABA path must consume a retry");
2688 }
2689
2690 #[test]
2691 fn legacy_nonce_chunks_survive_the_first_v3_root_swap_then_recover() {
2692 let old = "legacy-".repeat(700);
2693 let replacement = "replacement-".repeat(500);
2694 let followup = "followup-".repeat(500);
2695 let backend = legacy_v2(&old, "legacy-nonce");
2696
2697 let mut reader = InterleavingReader::new(backend, vec![replacement.as_str()]);
2698 assert_eq!(read(&mut reader), old);
2699 assert!(reader
2700 .inner
2701 .entries
2702 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2703 assert!(reader
2704 .inner
2705 .entries
2706 .contains_key(&WindowsCredentialSlot::LegacyV2Chunk {
2707 nonce: "legacy-nonce".to_string(),
2708 index: 0,
2709 }));
2710
2711 publish(&mut reader.inner, &followup);
2712 assert!(!reader
2713 .inner
2714 .entries
2715 .contains_key(&WindowsCredentialSlot::RetiredV2Manifest));
2716 assert!(!reader.inner.entries.keys().any(|slot| matches!(
2717 slot,
2718 WindowsCredentialSlot::LegacyV2Chunk { nonce, .. } if nonce == "legacy-nonce"
2719 )));
2720 }
2721
2722 #[test]
2723 fn crash_after_every_publish_mutation_preserves_a_readable_generation() {
2724 let old = "old-".repeat(1200);
2725 let current = "current-".repeat(900);
2726 let replacement = "replacement-".repeat(300);
2727 let mut base = MemoryWindowsBackend::default();
2728 publish(&mut base, &old);
2729 publish(&mut base, ¤t);
2730 base.reset_faults();
2731
2732 let mut successful = base.clone();
2733 publish(&mut successful, &replacement);
2734 let mutation_count = successful.mutation_calls;
2735 assert!(mutation_count >= 7, "exercise stage, commit, and cleanup");
2736
2737 for crash_after in 1..=mutation_count {
2738 let mut crashed = base.clone();
2739 crashed.crash_after_mutation = Some(crash_after);
2740 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2741 let _ = publish_windows_value(&mut crashed, &replacement);
2742 }));
2743 assert!(unwind.is_err(), "mutation {crash_after} must crash");
2744 crashed.reset_faults();
2745
2746 let observed = read(&mut crashed);
2747 assert!(
2748 observed == current || observed == replacement,
2749 "crash {crash_after} exposed neither committed generation"
2750 );
2751
2752 publish(&mut crashed, &replacement);
2753 publish(&mut crashed, "recovery-pass");
2754 publish(&mut crashed, &replacement);
2755 assert_eq!(read(&mut crashed), replacement);
2756 assert!(
2757 crashed.entries.len() <= 20,
2758 "crash {crash_after} leaked unbounded entries: {:?}",
2759 crashed.entries.keys().collect::<Vec<_>>()
2760 );
2761 }
2762 }
2763
2764 #[test]
2765 fn repeated_precommit_crashes_have_bounded_cardinality_and_recover_cleanup() {
2766 let old = "old-".repeat(900);
2767 let attempted = "attempted-".repeat(900);
2768 let recovered = "ok-".repeat(600);
2769 let attempted_chunks = split_on_chars(&attempted, CHUNK_CHARS).len();
2770 let old_chunks = split_on_chars(&old, CHUNK_CHARS).len();
2771 let mut backend = MemoryWindowsBackend::default();
2772 publish(&mut backend, &old);
2773
2774 for crash_index in 0..64 {
2775 backend.reset_faults();
2776 backend.crash_after_mutation = Some(1 + crash_index % attempted_chunks);
2777 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2778 let _ = publish_windows_value(&mut backend, &attempted);
2779 }));
2780 assert!(
2781 backend.entries.len() <= 1 + 2 + old_chunks + attempted_chunks,
2782 "attempt {crash_index} grew deterministic storage"
2783 );
2784 }
2785
2786 backend.reset_faults();
2787 publish(&mut backend, &recovered);
2788 assert_eq!(read(&mut backend), recovered);
2789 let recovered_chunks = split_on_chars(&recovered, CHUNK_CHARS).len();
2790 assert!(!backend.entries.keys().any(|slot| matches!(
2791 slot,
2792 WindowsCredentialSlot::V3Chunk {
2793 generation: ChunkGeneration::B,
2794 index,
2795 } if *index >= recovered_chunks
2796 )));
2797 assert_eq!(
2798 backend
2799 .entries
2800 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::B)),
2801 Some(&recovered_chunks.to_string())
2802 );
2803 }
2804
2805 #[test]
2806 fn staging_and_root_failures_leave_the_only_good_generation_readable() {
2807 let old = "old-".repeat(900);
2808 let replacement = "replacement-".repeat(500);
2809 for failed_slot in [
2810 WindowsCredentialSlot::V3Chunk {
2811 generation: ChunkGeneration::B,
2812 index: 1,
2813 },
2814 WindowsCredentialSlot::Root,
2815 ] {
2816 let mut backend = MemoryWindowsBackend::default();
2817 publish(&mut backend, &old);
2818 backend.fail_write = Some(FailureRule {
2819 slot: failed_slot,
2820 matches_to_skip: 0,
2821 });
2822
2823 let error = publish_windows_value(&mut backend, &replacement).unwrap_err();
2824 assert_backend_error(error, "injected");
2825 assert_eq!(read(&mut backend), old);
2826 }
2827 }
2828
2829 #[test]
2830 fn postcommit_cleanup_errors_report_deferred_success_and_recover_later() {
2831 let old = "old-".repeat(1400);
2832 let current = "current-".repeat(900);
2833 let replacement = "replacement-".repeat(200);
2834 let mut backend = MemoryWindowsBackend::default();
2835 publish(&mut backend, &old);
2836 publish(&mut backend, ¤t);
2837 backend.fail_delete = Some(FailureRule {
2838 slot: WindowsCredentialSlot::V3Chunk {
2839 generation: ChunkGeneration::A,
2840 index: 4,
2841 },
2842 matches_to_skip: 0,
2843 });
2844
2845 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2846 assert_eq!(cleanup.failures, 1);
2847 assert_eq!(read(&mut backend), replacement);
2848 assert_eq!(
2849 backend
2850 .entries
2851 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2852 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string()),
2853 "failed cleanup keeps the crash high-water for a later sweep"
2854 );
2855
2856 publish(&mut backend, "rotate-once");
2857 publish(&mut backend, &replacement);
2858 assert!(!backend.entries.keys().any(|slot| matches!(
2859 slot,
2860 WindowsCredentialSlot::V3Chunk {
2861 generation: ChunkGeneration::A,
2862 index,
2863 } if *index >= split_on_chars(&replacement, CHUNK_CHARS).len()
2864 )));
2865 }
2866
2867 #[test]
2868 fn postcommit_manifest_shrink_failure_keeps_recovery_high_water() {
2869 let old = "old-".repeat(1400);
2870 let current = "current-".repeat(900);
2871 let replacement = "replacement-".repeat(200);
2872 let mut backend = MemoryWindowsBackend::default();
2873 publish(&mut backend, &old);
2874 publish(&mut backend, ¤t);
2875 backend.fail_write = Some(FailureRule {
2876 slot: WindowsCredentialSlot::V3Manifest(ChunkGeneration::A),
2877 matches_to_skip: 1,
2878 });
2879
2880 let cleanup = publish_windows_value(&mut backend, &replacement).unwrap();
2881 assert_eq!(cleanup.failures, 1);
2882 assert_eq!(read(&mut backend), replacement);
2883 assert_eq!(
2884 backend
2885 .entries
2886 .get(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)),
2887 Some(&split_on_chars(&old, CHUNK_CHARS).len().to_string())
2888 );
2889 }
2890
2891 #[test]
2892 fn delete_cleanup_failure_retains_manifest_for_idempotent_recovery() {
2893 let value = "secret-".repeat(700);
2894 let mut backend = MemoryWindowsBackend::default();
2895 publish(&mut backend, &value);
2896 backend.fail_delete = Some(FailureRule {
2897 slot: WindowsCredentialSlot::V3Chunk {
2898 generation: ChunkGeneration::A,
2899 index: 0,
2900 },
2901 matches_to_skip: 0,
2902 });
2903
2904 let cleanup = delete_windows_value(&mut backend).unwrap();
2905 assert_eq!(cleanup.failures, 1);
2906 assert!(!backend.entries.contains_key(&WindowsCredentialSlot::Root));
2907 assert!(backend
2908 .entries
2909 .contains_key(&WindowsCredentialSlot::V3Manifest(ChunkGeneration::A)));
2910
2911 backend.reset_faults();
2912 assert_eq!(delete_windows_value(&mut backend).unwrap().failures, 0);
2913 assert!(backend.entries.is_empty());
2914 }
2915
2916 #[test]
2917 fn corrupt_cleanup_metadata_fails_before_root_or_chunks_are_deleted() {
2918 let old = "old-".repeat(900);
2919 let mut backend = MemoryWindowsBackend::default();
2920 publish(&mut backend, &old);
2921 let root_before = backend.root();
2922 backend.entries.insert(
2923 WindowsCredentialSlot::V3Manifest(ChunkGeneration::B),
2924 "not-a-count".to_string(),
2925 );
2926
2927 let error = publish_windows_value(&mut backend, "replacement").unwrap_err();
2928 assert_backend_error(error, "manifest");
2929 assert_eq!(backend.root(), root_before);
2930 assert_eq!(read(&mut backend), old);
2931
2932 let error = delete_windows_value(&mut backend).unwrap_err();
2933 assert_backend_error(error, "manifest");
2934 assert_eq!(backend.root(), root_before);
2935 assert_eq!(read(&mut backend), old);
2936 }
2937
2938 #[test]
2939 fn reader_retry_is_bounded_when_root_never_stabilizes() {
2940 let value_a = "a".repeat(2500);
2941 let value_b = "b".repeat(2500);
2942 let mut backend = MemoryWindowsBackend::default();
2943 publish(&mut backend, &value_a);
2944 let root_a = backend.root();
2945 publish(&mut backend, &value_b);
2946 let root_b = backend.root();
2947 backend.entries.remove(&WindowsCredentialSlot::V3Chunk {
2948 generation: ChunkGeneration::A,
2949 index: 0,
2950 });
2951 let mut churning = AlternatingRootBackend {
2952 inner: backend,
2953 roots: [root_a, root_b],
2954 root_reads: 0,
2955 };
2956
2957 let error = read_windows_value(&mut churning).unwrap_err();
2958 assert_backend_error(error, "changed during every read attempt");
2959 assert_eq!(churning.root_reads, WINDOWS_READ_ATTEMPTS * 2);
2960 }
2961
2962 struct InterleavingReader<'a> {
2963 inner: MemoryWindowsBackend,
2964 publications: Vec<&'a str>,
2965 root_reads: usize,
2966 }
2967
2968 impl<'a> InterleavingReader<'a> {
2969 fn new(inner: MemoryWindowsBackend, publications: Vec<&'a str>) -> Self {
2970 Self {
2971 inner,
2972 publications,
2973 root_reads: 0,
2974 }
2975 }
2976 }
2977
2978 impl WindowsCredentialBackend for InterleavingReader<'_> {
2979 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
2980 let captured = self.inner.read(slot)?;
2981 if slot == &WindowsCredentialSlot::Root && self.root_reads == 0 {
2982 for value in self.publications.drain(..) {
2983 publish_windows_value(&mut self.inner, value)?;
2984 }
2985 }
2986 if slot == &WindowsCredentialSlot::Root {
2987 self.root_reads += 1;
2988 }
2989 Ok(captured)
2990 }
2991
2992 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
2993 self.inner.write(slot, value)
2994 }
2995
2996 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
2997 self.inner.delete(slot)
2998 }
2999 }
3000
3001 struct AlternatingRootBackend {
3002 inner: MemoryWindowsBackend,
3003 roots: [String; 2],
3004 root_reads: usize,
3005 }
3006
3007 impl WindowsCredentialBackend for AlternatingRootBackend {
3008 fn read(&mut self, slot: &WindowsCredentialSlot) -> Result<Option<String>, SecretError> {
3009 if slot == &WindowsCredentialSlot::Root {
3010 let root = self.roots[self.root_reads % self.roots.len()].clone();
3011 self.root_reads += 1;
3012 return Ok(Some(root));
3013 }
3014 self.inner.read(slot)
3015 }
3016
3017 fn write(&mut self, slot: &WindowsCredentialSlot, value: &str) -> Result<(), SecretError> {
3018 self.inner.write(slot, value)
3019 }
3020
3021 fn delete(&mut self, slot: &WindowsCredentialSlot) -> Result<(), SecretError> {
3022 self.inner.delete(slot)
3023 }
3024 }
3025}
3026
3027#[cfg(test)]
3028mod tests {
3029 use super::*;
3030 use serde::{Deserialize, Serialize};
3031
3032 #[test]
3043 fn a_store_that_is_not_a_directory_is_a_backend_error_not_a_missing_secret() {
3044 let parent = tempfile::tempdir().unwrap();
3045 let not_a_dir = parent.path().join("blocked");
3046 std::fs::write(¬_a_dir, b"a regular file where the store should be").unwrap();
3047 let reference = SecretRef::with_default_service("SOME_KEY");
3048
3049 assert!(
3050 !file_backend_entry_is_merely_absent(¬_a_dir),
3051 "the platform-neutral discriminator must reject a regular-file store root"
3052 );
3053
3054 match file_backend_get(¬_a_dir, &reference) {
3055 Err(SecretError::Backend(_)) => {}
3056 other => panic!("unusable store must report a backend error, got {other:?}"),
3057 }
3058 match file_backend_delete(¬_a_dir, &reference) {
3059 Err(SecretError::Backend(_)) => {}
3060 other => panic!("unusable store must not report a successful delete, got {other:?}"),
3061 }
3062 assert!(
3063 !file_backend_status(¬_a_dir, &reference).exists,
3064 "status on an unusable store must not claim knowledge of the entry"
3065 );
3066 }
3067
3068 #[test]
3071 fn a_store_directory_that_does_not_exist_yet_is_still_not_found() {
3072 let parent = tempfile::tempdir().unwrap();
3073 let never_created = parent.path().join("not-created-yet");
3074 assert!(!never_created.exists());
3075 assert!(
3076 file_backend_entry_is_merely_absent(&never_created),
3077 "a missing directory beneath an existing directory is a normal first run"
3078 );
3079 let reference = SecretRef::with_default_service("SOME_KEY");
3080
3081 match file_backend_get(&never_created, &reference) {
3082 Err(SecretError::NotFound { .. }) => {}
3083 other => panic!("a first-run store has no secrets, it is not broken: {other:?}"),
3084 }
3085 assert!(
3086 file_backend_delete(&never_created, &reference).is_ok(),
3087 "deleting from a store that was never written is a no-op success"
3088 );
3089 assert!(!file_backend_status(&never_created, &reference).exists);
3090 }
3091
3092 #[test]
3093 fn a_missing_entry_in_a_real_directory_is_still_not_found() {
3094 let dir = tempfile::tempdir().unwrap();
3095 let reference = SecretRef::with_default_service("ABSENT_KEY");
3096
3097 match file_backend_get(dir.path(), &reference) {
3098 Err(SecretError::NotFound { .. }) => {}
3099 other => panic!("an absent entry in a usable store is NotFound, got {other:?}"),
3100 }
3101 assert!(
3102 file_backend_delete(dir.path(), &reference).is_ok(),
3103 "deleting an absent entry from a usable store is a no-op success"
3104 );
3105 assert!(!file_backend_status(dir.path(), &reference).exists);
3106 }
3107
3108 static STORE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3114
3115 fn lock_store() -> std::sync::MutexGuard<'static, ()> {
3116 STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner())
3117 }
3118
3119 struct IsolatedStoreFixture {
3124 _guard: std::sync::MutexGuard<'static, ()>,
3125 _dir: tempfile::TempDir,
3126 previous_dir: Option<std::ffi::OsString>,
3127 }
3128
3129 impl IsolatedStoreFixture {
3130 fn new() -> Self {
3131 let guard = lock_store();
3132 let dir = tempfile::tempdir().expect("isolated secret-store directory");
3133 let previous_dir = std::env::var_os("CAR_SECRETS_FILE_DIR");
3134 std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3135 assert_eq!(
3136 file_backend_dir().as_deref(),
3137 Some(dir.path()),
3138 "contract test must use the isolated file backend"
3139 );
3140 Self {
3141 _guard: guard,
3142 _dir: dir,
3143 previous_dir,
3144 }
3145 }
3146
3147 fn store(&self) -> SecretStore {
3148 SecretStore::new()
3149 }
3150 }
3151
3152 impl Drop for IsolatedStoreFixture {
3153 fn drop(&mut self) {
3154 match self.previous_dir.take() {
3155 Some(value) => std::env::set_var("CAR_SECRETS_FILE_DIR", value),
3156 None => std::env::remove_var("CAR_SECRETS_FILE_DIR"),
3157 }
3158 }
3159 }
3160
3161 struct ClearedEnv {
3162 name: &'static str,
3163 previous: Option<std::ffi::OsString>,
3164 }
3165
3166 impl ClearedEnv {
3167 fn new(name: &'static str) -> Self {
3168 let previous = std::env::var_os(name);
3169 std::env::remove_var(name);
3170 Self { name, previous }
3171 }
3172 }
3173
3174 impl Drop for ClearedEnv {
3175 fn drop(&mut self) {
3176 match self.previous.take() {
3177 Some(value) => std::env::set_var(self.name, value),
3178 None => std::env::remove_var(self.name),
3179 }
3180 }
3181 }
3182
3183 #[cfg(target_os = "macos")]
3186 fn test_service() -> String {
3187 format!(
3188 "car-secrets-tests-{}-{}",
3189 std::process::id(),
3190 std::time::SystemTime::now()
3193 .duration_since(std::time::UNIX_EPOCH)
3194 .map(|d| d.as_nanos())
3195 .unwrap_or(0)
3196 )
3197 }
3198
3199 #[cfg(target_os = "macos")]
3200 const NATIVE_KEYCHAIN_LANE: &str = "CAR_TEST_NATIVE_KEYCHAIN";
3201
3202 #[cfg(target_os = "macos")]
3203 fn run_native_keychain_lane() {
3204 assert!(
3205 std::env::var_os("CAR_SECRETS_FILE_DIR").is_none(),
3206 "native lane refuses CAR_SECRETS_FILE_DIR; run it against the provisioned keychain"
3207 );
3208 let store = SecretStore::new();
3209 let availability = store.availability();
3210 assert!(
3211 availability.available,
3212 "native keychain unavailable: {}",
3213 availability
3214 .reason
3215 .unwrap_or_else(|| "no reason reported".to_string())
3216 );
3217
3218 #[derive(Serialize, Deserialize, PartialEq, Debug)]
3219 struct Session {
3220 cookies: Vec<String>,
3221 expires_at: i64,
3222 }
3223
3224 let reference = SecretRef::new(test_service(), "provisioned-native-contracts");
3225 store
3226 .delete(&reference)
3227 .expect("clean native fixture before run");
3228 assert!(matches!(
3229 store.get(&reference),
3230 Err(SecretError::NotFound { .. })
3231 ));
3232 store.put(&reference, "abc\n").expect("write native secret");
3233 assert_eq!(store.get(&reference).unwrap(), "abc\n");
3234 let status = store.status(&reference).unwrap();
3235 assert!(status.exists);
3236 assert!(!serde_json::to_string(&status).unwrap().contains("abc"));
3237 let session = Session {
3238 cookies: vec!["a=1".into(), "b=2".into()],
3239 expires_at: 1_700_000_000,
3240 };
3241 store.put_json(&reference, &session).unwrap();
3242 assert_eq!(store.get_json::<Session>(&reference).unwrap(), session);
3243 store
3244 .delete(&reference)
3245 .expect("clean native fixture after run");
3246 store
3247 .delete(&reference)
3248 .expect("native delete is idempotent");
3249 assert!(!store.status(&reference).unwrap().exists);
3250 }
3251
3252 #[derive(Clone)]
3266 struct BufWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3267
3268 impl std::io::Write for BufWriter {
3269 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3270 self.0.lock().unwrap().extend_from_slice(buf);
3271 Ok(buf.len())
3272 }
3273 fn flush(&mut self) -> std::io::Result<()> {
3274 Ok(())
3275 }
3276 }
3277
3278 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
3279 type Writer = BufWriter;
3280 fn make_writer(&'a self) -> Self::Writer {
3281 self.clone()
3282 }
3283 }
3284
3285 #[test]
3286 fn file_backend_roundtrip_and_warn_under_debug() {
3287 const CHILD: &str = "CAR_TEST_FILE_BACKEND_WARNING_CHILD";
3288 if std::env::var_os(CHILD).is_none() {
3289 let output =
3290 std::process::Command::new(std::env::current_exe().expect("test executable"))
3291 .args([
3292 "--exact",
3293 "tests::file_backend_roundtrip_and_warn_under_debug",
3294 "--nocapture",
3295 ])
3296 .env(CHILD, "1")
3297 .env_remove("CAR_SECRETS_FILE_DIR")
3298 .env_remove("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING")
3299 .env_remove("CAR_KEYCHAIN_PROOF_ROOT")
3300 .env_remove("CAR_KEYCHAIN_PATH")
3301 .output()
3302 .expect("spawn isolated file-backend warning test");
3303 assert!(
3304 output.status.success(),
3305 "isolated file-backend warning test failed\nstdout:\n{}\nstderr:\n{}",
3306 String::from_utf8_lossy(&output.stdout),
3307 String::from_utf8_lossy(&output.stderr),
3308 );
3309 return;
3310 }
3311
3312 if std::env::var_os("CAR_TEST_PRECONSUME_FILE_BACKEND_WARNING").is_some() {
3316 let _ = file_backend_dir();
3317 }
3318 let _guard = lock_store();
3321 #[allow(clippy::assertions_on_constants)]
3325 {
3326 assert!(
3327 cfg!(debug_assertions),
3328 "the crate test suite runs in debug; the file backend depends on it"
3329 );
3330 }
3331
3332 let dir = std::env::temp_dir().join(format!(
3333 "car-secrets-filebackend-{}-{}",
3334 std::process::id(),
3335 std::time::SystemTime::now()
3336 .duration_since(std::time::UNIX_EPOCH)
3337 .map(|d| d.as_nanos())
3338 .unwrap_or(0)
3339 ));
3340 std::fs::create_dir_all(&dir).unwrap();
3341 std::env::set_var("CAR_SECRETS_FILE_DIR", &dir);
3342
3343 let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
3346 let subscriber = tracing_subscriber::fmt()
3347 .with_writer(BufWriter(buf.clone()))
3348 .with_max_level(tracing::Level::WARN)
3349 .finish();
3350 tracing::subscriber::with_default(subscriber, || {
3351 assert_eq!(
3354 file_backend_dir().as_deref(),
3355 Some(dir.as_path()),
3356 "CAR_SECRETS_FILE_DIR must be honored under debug_assertions"
3357 );
3358 });
3359 let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3360 assert!(
3361 logged.contains("PLAINTEXT ON DISK"),
3362 "the file backend must emit the one-time PLAINTEXT warning, got logs: {logged:?}"
3363 );
3364
3365 let store = SecretStore::new();
3366 let check = store.availability();
3368 assert!(check.available, "file backend must report available");
3369 assert!(check.reason.is_none());
3370
3371 let r = SecretRef::new("svc", "key");
3373 store.put(&r, "xoxb-plaintext-value").unwrap();
3374 assert_eq!(store.get(&r).unwrap(), "xoxb-plaintext-value");
3375 let on_disk = std::fs::read_to_string(file_backend_path(&dir, &r)).unwrap();
3377 assert_eq!(on_disk, "xoxb-plaintext-value");
3378 store.delete(&r).unwrap();
3379 match store.get(&r) {
3380 Err(SecretError::NotFound { .. }) => {}
3381 other => panic!("expected NotFound after delete, got {other:?}"),
3382 }
3383
3384 for key in [
3388 OPENROUTER_OAUTH_KEY,
3389 PARSLEE_ACCESS_TOKEN_KEY,
3390 PARSLEE_REFRESH_TOKEN_KEY,
3391 PARSLEE_EXPIRES_AT_KEY,
3392 PARSLEE_API_BASE_KEY,
3393 PARSLEE_ACCOUNTS_KEY,
3394 "PARSLEE_TOKENS_account-1",
3395 PARSLEE_AUTH_GENERATION_KEY,
3396 PARSLEE_AUTH_COMPLETION_KEY,
3397 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3398 PARSLEE_AUTH_STATE_V2_KEY,
3399 ] {
3400 let private = SecretRef::new(DEFAULT_SERVICE, key);
3401 assert!(is_daemon_private_secret(&private.service, &private.key));
3402 store.put(&private, "internal-test-value").unwrap();
3403 assert_eq!(store.get(&private).unwrap(), "internal-test-value");
3404 store.delete(&private).unwrap();
3405 assert!(matches!(
3406 store.get(&private),
3407 Err(SecretError::NotFound { .. })
3408 ));
3409 }
3410
3411 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3413 let _ = std::fs::remove_dir_all(&dir);
3414 }
3415
3416 #[test]
3417 fn every_parslee_auth_slot_is_private_to_the_dedicated_auth_surface() {
3418 for key in [
3419 PARSLEE_ACCESS_TOKEN_KEY,
3420 PARSLEE_REFRESH_TOKEN_KEY,
3421 PARSLEE_EXPIRES_AT_KEY,
3422 PARSLEE_API_BASE_KEY,
3423 PARSLEE_ACCOUNTS_KEY,
3424 "PARSLEE_TOKENS_account-1",
3425 PARSLEE_ACTIVE_ACCOUNT_ID_KEY,
3426 PARSLEE_AUTH_GENERATION_KEY,
3427 PARSLEE_AUTH_COMPLETION_KEY,
3428 PARSLEE_AUTH_STATE_V2_KEY,
3429 ] {
3430 assert!(
3431 is_daemon_private_secret(DEFAULT_SERVICE, key),
3432 "{key} must be unreachable through generic secret surfaces"
3433 );
3434 assert!(
3435 !is_daemon_private_secret("other-service", key),
3436 "reservation must remain scoped to the CAR service"
3437 );
3438 }
3439
3440 assert!(!is_daemon_private_secret(
3441 DEFAULT_SERVICE,
3442 "OPENROUTER_API_KEY"
3443 ));
3444 for key in [
3445 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunk0"),
3446 format!("{PARSLEE_AUTH_STATE_V2_KEY}#chunkv2#nonce-1#0"),
3447 format!("{OPENROUTER_OAUTH_KEY}#chunk17"),
3448 format!("{OPENROUTER_OAUTH_KEY}#chunkv2#nonce-2#3"),
3449 ] {
3450 assert!(
3451 is_daemon_private_secret(DEFAULT_SERVICE, &key),
3452 "{key} is derived from a daemon-private root"
3453 );
3454 assert!(!is_daemon_private_secret("other-service", &key));
3455 }
3456 }
3457
3458 #[cfg(target_os = "macos")]
3459 #[test]
3460 fn security_cli_output_large_helper() {
3461 if std::env::var_os("CAR_SECURITY_OUTPUT_HELPER").is_none() {
3462 return;
3463 }
3464 use std::io::Write;
3465 let payload = vec![b'x'; 128 * 1024];
3466 std::io::stdout().write_all(&payload).unwrap();
3467 std::io::stdout().flush().unwrap();
3468 std::io::stderr().write_all(&payload).unwrap();
3469 std::io::stderr().flush().unwrap();
3470 }
3471
3472 #[cfg(target_os = "macos")]
3473 #[test]
3474 fn security_cli_output_drains_large_stdout_and_stderr() {
3475 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3476 command
3477 .args([
3478 "--exact",
3479 "tests::security_cli_output_large_helper",
3480 "--nocapture",
3481 ])
3482 .env("CAR_SECURITY_OUTPUT_HELPER", "1");
3483
3484 let output = SecurityCliExecutor::new(std::time::Duration::from_secs(15))
3485 .output(command, "test", || Ok(()))
3486 .unwrap();
3487
3488 assert!(output.success, "{output:?}");
3489 assert!(output.stdout.len() >= 128 * 1024);
3490 assert!(output.stderr.len() >= 128 * 1024);
3491 }
3492
3493 #[cfg(target_os = "macos")]
3494 pub(super) struct FakeSecurityCli {
3495 outputs:
3496 std::cell::RefCell<std::collections::VecDeque<Result<SecurityCliOutput, SecretError>>>,
3497 calls: std::cell::RefCell<Vec<Vec<String>>>,
3498 }
3499
3500 #[cfg(target_os = "macos")]
3501 impl FakeSecurityCli {
3502 pub(super) fn new(outputs: Vec<Result<SecurityCliOutput, SecretError>>) -> Self {
3503 Self {
3504 outputs: std::cell::RefCell::new(outputs.into()),
3505 calls: std::cell::RefCell::new(Vec::new()),
3506 }
3507 }
3508
3509 pub(super) fn calls(&self) -> Vec<Vec<String>> {
3510 self.calls.borrow().clone()
3511 }
3512 }
3513
3514 #[cfg(target_os = "macos")]
3515 impl SecurityCli for FakeSecurityCli {
3516 fn output(&self, args: &[&str]) -> Result<SecurityCliOutput, SecretError> {
3517 self.calls
3518 .borrow_mut()
3519 .push(args.iter().map(|arg| (*arg).to_string()).collect());
3520 self.outputs
3521 .borrow_mut()
3522 .pop_front()
3523 .expect("missing fake security output")
3524 }
3525 }
3526
3527 #[cfg(target_os = "macos")]
3528 pub(super) fn security_output(
3529 code: i32,
3530 stdout: impl Into<Vec<u8>>,
3531 stderr: impl Into<Vec<u8>>,
3532 ) -> Result<SecurityCliOutput, SecretError> {
3533 Ok(SecurityCliOutput {
3534 success: code == 0,
3535 code: Some(code),
3536 stdout: stdout.into(),
3537 stderr: stderr.into(),
3538 })
3539 }
3540
3541 #[cfg(target_os = "macos")]
3542 fn args(values: &[&str]) -> Vec<String> {
3543 values.iter().map(|value| (*value).to_string()).collect()
3544 }
3545
3546 #[cfg(target_os = "macos")]
3549 #[test]
3550 fn availability_probe_reports_unavailable_when_reads_succeed_but_writes_are_denied() {
3551 struct ReadableButWriteDeniedCli;
3552
3553 impl SecurityCli for ReadableButWriteDeniedCli {
3554 fn output(&self, args: &[&str]) -> Result<SecurityCliOutput, SecretError> {
3555 match args.first().copied() {
3556 Some("find-generic-password") => security_output(0, "", ""),
3557 Some("add-generic-password") => security_output(
3558 152,
3559 "",
3560 "security: SecKeychainItemCreateFromContent: User interaction is not allowed.",
3561 ),
3562 other => panic!("unexpected security command: {other:?}"),
3563 }
3564 }
3565 }
3566
3567 let check = mac_availability_via_security_cli_with(&ReadableButWriteDeniedCli);
3568
3569 assert!(!check.available);
3570 let reason = check
3571 .reason
3572 .expect("write-denied probe must carry a reason");
3573 assert!(
3574 reason.contains("User interaction is not allowed"),
3575 "reason should carry the write error, got {reason:?}"
3576 );
3577 }
3578
3579 #[cfg(target_os = "macos")]
3580 #[test]
3581 fn availability_probe_reads_then_writes_the_retained_sentinel() {
3582 let cli = FakeSecurityCli::new(vec![
3583 security_output(SECURITY_ERR_SEC_ITEM_NOT_FOUND, "", ""),
3584 security_output(0, "", ""),
3585 ]);
3586
3587 let check = mac_availability_via_security_cli_with(&cli);
3588
3589 assert!(check.available, "{:?}", check.reason);
3590 assert_eq!(
3591 cli.calls(),
3592 vec![
3593 args(&[
3594 "find-generic-password",
3595 "-s",
3596 "car-internal",
3597 "-a",
3598 "__availability_probe__",
3599 ]),
3600 args(&[
3601 "add-generic-password",
3602 "-U",
3603 "-A",
3604 "-s",
3605 "car-internal",
3606 "-a",
3607 "__availability_probe__",
3608 "-w",
3609 "car-availability-probe",
3610 ]),
3611 ]
3612 );
3613 }
3614
3615 #[cfg(target_os = "macos")]
3616 #[test]
3617 fn keychain_status_requires_unlocked_readable_and_requested_access() {
3618 assert_eq!(
3619 MacKeychainAccess::for_security_args(&["find-generic-password"]),
3620 MacKeychainAccess::Read
3621 );
3622 assert_eq!(
3623 MacKeychainAccess::for_security_args(&["future-security-command"]),
3624 MacKeychainAccess::Write,
3625 "unknown helper operations must fail closed to write access"
3626 );
3627 assert!(mac_keychain_status_allows(
3628 KEYCHAIN_UNLOCKED | KEYCHAIN_READABLE,
3629 MacKeychainAccess::Read
3630 ));
3631 assert!(!mac_keychain_status_allows(
3632 KEYCHAIN_READABLE | KEYCHAIN_WRITABLE,
3633 MacKeychainAccess::Read
3634 ));
3635 let locked = mac_keychain_preflight_status(
3636 KEYCHAIN_READABLE | KEYCHAIN_WRITABLE,
3637 MacKeychainAccess::Read,
3638 )
3639 .unwrap_err();
3640 assert!(matches!(locked, SecretError::Unavailable(reason) if reason.contains("locked")));
3641 assert!(!mac_keychain_status_allows(
3642 KEYCHAIN_UNLOCKED | KEYCHAIN_WRITABLE,
3643 MacKeychainAccess::Read
3644 ));
3645 assert!(!mac_keychain_status_allows(
3646 KEYCHAIN_UNLOCKED | KEYCHAIN_READABLE,
3647 MacKeychainAccess::Write
3648 ));
3649 assert!(mac_keychain_status_allows(
3650 KEYCHAIN_UNLOCKED | KEYCHAIN_READABLE | KEYCHAIN_WRITABLE,
3651 MacKeychainAccess::Write
3652 ));
3653 }
3654
3655 #[cfg(target_os = "macos")]
3659 #[test]
3660 fn locked_keychain_fails_before_security_child_spawn() {
3661 let dir = tempfile::tempdir().unwrap();
3662 let marker = dir.path().join("security-child-spawned");
3663 let mut command = std::process::Command::new("/bin/sh");
3664 command
3665 .args(["-c", r#"printf spawned > "$1""#, "security-probe"])
3666 .arg(&marker);
3667
3668 let result = SecurityCliExecutor::new(std::time::Duration::from_secs(15)).output(
3669 command,
3670 "find-generic-password",
3671 || {
3672 Err(SecretError::Unavailable(
3673 "macOS keychain is locked".to_string(),
3674 ))
3675 },
3676 );
3677
3678 assert!(matches!(result, Err(SecretError::Unavailable(_))));
3679 assert!(
3680 !marker.exists(),
3681 "security child ran before keychain preflight"
3682 );
3683 }
3684
3685 #[cfg(target_os = "macos")]
3687 #[test]
3688 fn security_child_returns_naturally_without_a_car_kill() {
3689 let mut command = std::process::Command::new("/bin/sh");
3690 command.args(["-c", "sleep 0.05; printf natural-exit"]);
3691
3692 let output = SecurityCliExecutor::new(std::time::Duration::from_secs(15))
3693 .output(command, "test", || Ok(()))
3694 .unwrap();
3695
3696 assert!(output.success);
3697 assert_eq!(output.stdout, b"natural-exit");
3698 }
3699
3700 #[cfg(target_os = "macos")]
3703 #[test]
3704 fn stalled_security_helper_is_bounded_owned_and_not_duplicated() {
3705 use std::sync::atomic::Ordering;
3706 use std::time::{Duration, Instant};
3707 let dir = tempfile::tempdir().unwrap();
3708 let marker = dir.path().join("started");
3709 let release = dir.path().join("release");
3710 let terminated = dir.path().join("terminated");
3711 let exited = dir.path().join("exited");
3712 let executor = SecurityCliExecutor::new(Duration::from_millis(100));
3713 let mut command = std::process::Command::new("/bin/sh");
3714 command
3715 .args([
3716 "-c",
3717 r#"
3718 trap 'printf signal > "$3"; exit 3' TERM INT HUP
3719 printf started >> "$1"
3720 while [ ! -f "$2" ]; do sleep 0.01; done
3721 printf natural-exit > "$4"
3722 "#,
3723 "fake-security",
3724 ])
3725 .arg(&marker)
3726 .arg(&release)
3727 .arg(&terminated)
3728 .arg(&exited);
3729 struct Release(std::path::PathBuf);
3731 impl Drop for Release {
3732 fn drop(&mut self) {
3733 let _ = std::fs::write(&self.0, b"release");
3734 }
3735 }
3736 let cleanup = Release(release.clone());
3737 let started = Instant::now();
3738 let first = executor.output(command, "find-generic-password", || {
3739 mac_keychain_preflight_status(
3740 KEYCHAIN_UNLOCKED | KEYCHAIN_READABLE,
3741 MacKeychainAccess::Read,
3742 )
3743 });
3744 assert!(
3745 matches!(first, Err(SecretError::Unavailable(message)) if message.contains("pending"))
3746 );
3747 assert!(started.elapsed() < Duration::from_secs(2));
3748 let mut retry = std::process::Command::new("/bin/sh");
3750 retry
3751 .args(["-c", r#"printf duplicate >> "$1""#, "fake-security"])
3752 .arg(&marker);
3753 let started = Instant::now();
3754 let second = executor.output(retry, "find-generic-password", || Ok(()));
3755 assert!(
3756 matches!(second, Err(SecretError::Unavailable(message)) if message.contains("no additional helper"))
3757 );
3758 assert!(started.elapsed() < Duration::from_secs(2));
3759 drop(cleanup);
3760 let deadline = Instant::now() + Duration::from_secs(5);
3761 while executor.in_flight.load(Ordering::Acquire) {
3762 assert!(
3763 Instant::now() < deadline,
3764 "natural exit must clear owned flight"
3765 );
3766 std::thread::sleep(Duration::from_millis(5));
3767 }
3768 assert_eq!(std::fs::read_to_string(marker).unwrap(), "started");
3769 assert_eq!(std::fs::read_to_string(exited).unwrap(), "natural-exit");
3770 assert!(
3771 !terminated.exists(),
3772 "helper must never receive a termination signal"
3773 );
3774 let mut recovered = std::process::Command::new("/bin/sh");
3775 recovered.args(["-c", "printf recovered"]);
3776 let output = executor
3777 .output(recovered, "find-generic-password", || Ok(()))
3778 .unwrap();
3779 assert!(output.success);
3780 assert_eq!(output.stdout, b"recovered");
3781 }
3782
3783 #[cfg(target_os = "macos")]
3784 fn assert_access_denied_contains(err: SecretError, expected: &str) {
3785 match err {
3786 SecretError::AccessDenied { message } => assert!(
3787 message.contains(expected),
3788 "expected access-denied error to contain {expected:?}, got {message:?}"
3789 ),
3790 other => panic!("expected AccessDenied, got {:?}", other),
3791 }
3792 }
3793
3794 #[cfg(target_os = "macos")]
3795 #[test]
3796 fn mac_security_errors_are_typed_for_recovery() {
3797 assert!(matches!(
3798 classify_security_error(-128, "user canceled"),
3799 SecretError::UserCancelled { .. }
3800 ));
3801 assert!(matches!(
3802 classify_security_error(-25293, "authorization denied"),
3803 SecretError::AccessDenied { .. }
3804 ));
3805 }
3806
3807 #[cfg(target_os = "macos")]
3808 struct IsolatedKeychainFixture {
3809 _temp: tempfile::TempDir,
3810 proof_root: std::path::PathBuf,
3811 valid_path: std::path::PathBuf,
3812 symlink_path: std::path::PathBuf,
3813 outside_path: std::path::PathBuf,
3814 public_path: std::path::PathBuf,
3815 directory_path: std::path::PathBuf,
3816 public_root: std::path::PathBuf,
3817 }
3818
3819 #[cfg(target_os = "macos")]
3820 impl IsolatedKeychainFixture {
3821 fn new() -> Self {
3822 use std::os::unix::fs::{symlink, PermissionsExt};
3823
3824 let temp = tempfile::tempdir().unwrap();
3825 let proof_root = temp.path().join("proof");
3826 std::fs::create_dir(&proof_root).unwrap();
3827 std::fs::set_permissions(&proof_root, std::fs::Permissions::from_mode(0o700)).unwrap();
3828
3829 let valid_path = proof_root.join("valid.keychain-db");
3830 std::fs::write(&valid_path, b"keychain fixture").unwrap();
3831 std::fs::set_permissions(&valid_path, std::fs::Permissions::from_mode(0o600)).unwrap();
3832
3833 let symlink_path = proof_root.join("linked.keychain-db");
3834 symlink(&valid_path, &symlink_path).unwrap();
3835
3836 let outside_path = temp.path().join("outside.keychain-db");
3837 std::fs::write(&outside_path, b"outside fixture").unwrap();
3838 std::fs::set_permissions(&outside_path, std::fs::Permissions::from_mode(0o600))
3839 .unwrap();
3840
3841 let public_path = proof_root.join("public.keychain-db");
3842 std::fs::write(&public_path, b"public fixture").unwrap();
3843 std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644)).unwrap();
3844
3845 let directory_path = proof_root.join("directory.keychain-db");
3846 std::fs::create_dir(&directory_path).unwrap();
3847
3848 let public_root = temp.path().join("public-proof");
3849 std::fs::create_dir(&public_root).unwrap();
3850 std::fs::set_permissions(&public_root, std::fs::Permissions::from_mode(0o755)).unwrap();
3851
3852 Self {
3853 _temp: temp,
3854 proof_root,
3855 valid_path,
3856 symlink_path,
3857 outside_path,
3858 public_path,
3859 directory_path,
3860 public_root,
3861 }
3862 }
3863
3864 fn proof_root(&self) -> &std::path::Path {
3865 &self.proof_root
3866 }
3867
3868 fn valid_path(&self) -> &std::path::Path {
3869 &self.valid_path
3870 }
3871
3872 fn symlink_path(&self) -> &std::path::Path {
3873 &self.symlink_path
3874 }
3875
3876 fn outside_path(&self) -> &std::path::Path {
3877 &self.outside_path
3878 }
3879
3880 fn public_path(&self) -> &std::path::Path {
3881 &self.public_path
3882 }
3883
3884 fn directory_path(&self) -> &std::path::Path {
3885 &self.directory_path
3886 }
3887
3888 fn public_root(&self) -> &std::path::Path {
3889 &self.public_root
3890 }
3891 }
3892
3893 #[cfg(target_os = "macos")]
3894 #[test]
3895 fn isolated_keychain_must_be_absolute_private_regular_owned_and_under_proof_root() {
3896 let fixture = IsolatedKeychainFixture::new();
3897 assert!(validate_keychain_path(fixture.valid_path(), fixture.proof_root()).is_ok());
3898 assert!(validate_keychain_path(
3899 std::path::Path::new("relative.keychain-db"),
3900 fixture.proof_root()
3901 )
3902 .is_err());
3903 assert!(validate_keychain_path(fixture.symlink_path(), fixture.proof_root()).is_err());
3904 assert!(validate_keychain_path(fixture.outside_path(), fixture.proof_root()).is_err());
3905 assert!(validate_keychain_path(fixture.public_path(), fixture.proof_root()).is_err());
3906 assert!(validate_keychain_path(fixture.directory_path(), fixture.proof_root()).is_err());
3907 assert!(validate_keychain_path(fixture.valid_path(), fixture.public_root()).is_err());
3908 }
3909
3910 #[test]
3911 fn secret_store_activity_counts_only_aggregate_public_operation_attempts() {
3912 let _guard = lock_store();
3913 let dir = tempfile::tempdir().unwrap();
3914 std::env::set_var("CAR_SECRETS_FILE_DIR", dir.path());
3915 let before = secret_store_activity();
3916 let store = SecretStore::new();
3917 let secret = SecretRef::new("activity-test", "credential");
3918
3919 assert!(store.availability().available);
3920 store.put(&secret, "sensitive-value").unwrap();
3921 let _ = store.get(&secret).unwrap();
3922 let _ = store.status(&secret).unwrap();
3923 store.publish(&secret, "replacement-value").unwrap();
3924 store.delete(&secret).unwrap();
3925
3926 let after = secret_store_activity();
3927 assert_eq!(after.get_attempts - before.get_attempts, 1);
3928 assert_eq!(after.status_attempts - before.status_attempts, 1);
3929 assert_eq!(
3930 after.availability_attempts - before.availability_attempts,
3931 1
3932 );
3933 assert_eq!(after.write_attempts - before.write_attempts, 2);
3934 assert_eq!(after.delete_attempts - before.delete_attempts, 1);
3935
3936 let encoded = serde_json::to_string(&after).unwrap();
3937 assert!(!encoded.contains("activity-test"));
3938 assert!(!encoded.contains("credential"));
3939 assert!(!encoded.contains("sensitive-value"));
3940 assert!(!encoded.contains(dir.path().to_string_lossy().as_ref()));
3941 std::env::remove_var("CAR_SECRETS_FILE_DIR");
3942 }
3943
3944 #[test]
3953 fn roundtrip_string() {
3954 #[cfg(target_os = "macos")]
3955 if std::env::var_os(NATIVE_KEYCHAIN_LANE).is_some() {
3956 run_native_keychain_lane();
3957 return;
3958 }
3959
3960 let fixture = IsolatedStoreFixture::new();
3961 let store = fixture.store();
3962 let r = SecretRef::new("isolated-contract", "roundtrip");
3963 store.put(&r, "hello world").unwrap();
3964 assert_eq!(store.get(&r).unwrap(), "hello world");
3965 assert!(store.status(&r).unwrap().exists);
3966 store.delete(&r).unwrap();
3967 assert!(!store.status(&r).unwrap().exists);
3968 }
3969
3970 #[test]
3971 fn env_resolution_returns_before_any_secret_store_read() {
3972 const NAME: &str = "CAR_SECRETS_ENV_FIRST_TEST";
3973 let _fixture = IsolatedStoreFixture::new();
3974 let _env = ClearedEnv::new(NAME);
3975 std::env::set_var(NAME, "from-environment");
3976 let before = secret_store_activity();
3977
3978 assert_eq!(
3979 resolve_env_or_keychain(NAME),
3980 Some("from-environment".to_string())
3981 );
3982
3983 let after = secret_store_activity();
3984 assert_eq!(after.get_attempts, before.get_attempts);
3985 assert_eq!(after.availability_attempts, before.availability_attempts);
3986 }
3987
3988 #[test]
3989 fn env_resolution_reads_the_keychain_without_an_availability_write() {
3990 let fixture = IsolatedStoreFixture::new();
3991 let _env = ClearedEnv::new("CAR_SECRETS_RESOLUTION_READ_ONLY_TEST");
3992 let store = fixture.store();
3993 let reference = SecretRef::with_default_service("CAR_SECRETS_RESOLUTION_READ_ONLY_TEST");
3994 store.put(&reference, "from-keychain").unwrap();
3995
3996 let before = secret_store_activity();
3997 assert_eq!(
3998 resolve_env_or_keychain("CAR_SECRETS_RESOLUTION_READ_ONLY_TEST"),
3999 Some("from-keychain".to_string())
4000 );
4001 let after = secret_store_activity();
4002
4003 assert_eq!(after.get_attempts, before.get_attempts + 1);
4004 assert_eq!(after.availability_attempts, before.availability_attempts);
4005 assert_eq!(after.write_attempts, before.write_attempts);
4006 assert_eq!(after.delete_attempts, before.delete_attempts);
4007 store.delete(&reference).unwrap();
4008 }
4009
4010 #[test]
4011 fn roundtrip_string_with_trailing_newline() {
4012 let fixture = IsolatedStoreFixture::new();
4013 let store = fixture.store();
4014 let r = SecretRef::new("isolated-contract", "roundtrip-newline");
4015 let value = "abc\n";
4016 store.put(&r, value).unwrap();
4017 assert_eq!(store.get(&r).unwrap(), value);
4018 store.delete(&r).unwrap();
4019 }
4020
4021 #[test]
4022 fn get_missing_returns_not_found() {
4023 let fixture = IsolatedStoreFixture::new();
4024 let store = fixture.store();
4025 let r = SecretRef::new("isolated-contract", "never-written");
4026 match store.get(&r) {
4027 Err(SecretError::NotFound { .. }) => (),
4028 other => panic!("expected NotFound, got {:?}", other),
4029 }
4030 }
4031
4032 #[test]
4033 fn delete_missing_is_idempotent() {
4034 let fixture = IsolatedStoreFixture::new();
4035 let store = fixture.store();
4036 let r = SecretRef::new("isolated-contract", "missing");
4037 store.delete(&r).unwrap();
4038 store.delete(&r).unwrap();
4039 }
4040
4041 #[test]
4042 fn json_roundtrip() {
4043 let fixture = IsolatedStoreFixture::new();
4044 #[derive(Serialize, Deserialize, PartialEq, Debug)]
4045 struct Session {
4046 cookies: Vec<String>,
4047 expires_at: i64,
4048 }
4049 let store = fixture.store();
4050 let r = SecretRef::new("isolated-contract", "session");
4051 let s = Session {
4052 cookies: vec!["a=1".into(), "b=2".into()],
4053 expires_at: 1_700_000_000,
4054 };
4055 store.put_json(&r, &s).unwrap();
4056 let back: Session = store.get_json(&r).unwrap();
4057 assert_eq!(back, s);
4058 store.delete(&r).unwrap();
4059 }
4060
4061 #[test]
4062 fn status_no_leak() {
4063 let fixture = IsolatedStoreFixture::new();
4064 let store = fixture.store();
4065 let r = SecretRef::new("isolated-contract", "status");
4066 store.put(&r, "secret-payload").unwrap();
4067 let st = store.status(&r).unwrap();
4068 let encoded = serde_json::to_string(&st).unwrap();
4069 assert!(!encoded.contains("secret-payload"));
4070 store.delete(&r).unwrap();
4071 }
4072
4073 #[cfg(target_os = "macos")]
4074 #[test]
4075 fn mac_get_uses_security_cli_and_maps_success() {
4076 let cli = FakeSecurityCli::new(vec![security_output(
4077 0,
4078 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
4079 b"password: \"secret\"\n",
4080 )]);
4081 let r = SecretRef::new("svc", "key");
4082
4083 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
4084 assert_eq!(
4085 cli.calls(),
4086 vec![args(&[
4087 "find-generic-password",
4088 "-s",
4089 "svc",
4090 "-a",
4091 "key",
4092 "-g"
4093 ])]
4094 );
4095 }
4096
4097 #[cfg(target_os = "macos")]
4101 #[test]
4102 fn successful_read_preserves_the_item_and_persisted_grant() {
4103 let cli = FakeSecurityCli::new(vec![security_output(
4104 0,
4105 b"keychain: isolated-test.keychain-db\n",
4106 b"password: \"secret\"\n",
4107 )]);
4108 let r = SecretRef::new("car-test-0o9-prompt-persistence", "credential");
4109
4110 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "secret");
4111 assert_eq!(
4112 cli.calls(),
4113 vec![args(&[
4114 "find-generic-password",
4115 "-s",
4116 "car-test-0o9-prompt-persistence",
4117 "-a",
4118 "credential",
4119 "-g",
4120 ])],
4121 "an approved read must never rewrite or recreate the item"
4122 );
4123 }
4124
4125 #[cfg(target_os = "macos")]
4126 #[test]
4127 fn mac_get_decodes_hex_password_output_with_trailing_newline() {
4128 let cli = FakeSecurityCli::new(vec![security_output(
4129 0,
4130 b"keychain: \"/Users/example/Library/Keychains/login.keychain-db\"\n",
4131 b"password: 0x6162630A \"abc\\012\"\n",
4132 )]);
4133 let r = SecretRef::new("svc", "key");
4134
4135 assert_eq!(mac_get_via_security_cli_with(&r, &cli).unwrap(), "abc\n");
4136 assert_eq!(
4137 cli.calls(),
4138 vec![args(&[
4139 "find-generic-password",
4140 "-s",
4141 "svc",
4142 "-a",
4143 "key",
4144 "-g"
4145 ])]
4146 );
4147 }
4148
4149 #[cfg(target_os = "macos")]
4150 #[test]
4151 fn mac_get_maps_not_found_and_access_denied_without_fallback() {
4152 let r = SecretRef::new("svc", "missing");
4153 let cli = FakeSecurityCli::new(vec![security_output(
4154 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4155 b"",
4156 b"The specified item could not be found in the keychain.\n",
4157 )]);
4158
4159 match mac_get_via_security_cli_with(&r, &cli) {
4160 Err(SecretError::NotFound { service, key }) => {
4161 assert_eq!(service, "svc");
4162 assert_eq!(key, "missing");
4163 }
4164 other => panic!("expected NotFound, got {:?}", other),
4165 }
4166 assert_eq!(cli.calls().len(), 1);
4167
4168 let cli = FakeSecurityCli::new(vec![security_output(
4169 51,
4170 b"",
4171 b"User interaction is not allowed.\n",
4172 )]);
4173 let err = mac_get_via_security_cli_with(&r, &cli).unwrap_err();
4174 assert_access_denied_contains(err, "User interaction is not allowed.");
4175 assert_eq!(cli.calls().len(), 1);
4176 }
4177
4178 #[cfg(target_os = "macos")]
4179 #[test]
4180 fn mac_status_uses_security_cli_and_maps_results() {
4181 let r = SecretRef::new("svc", "key");
4182 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4183
4184 let status = mac_status_via_security_cli_with(&r, &cli).unwrap();
4185 assert!(status.exists);
4186 assert_eq!(
4187 cli.calls(),
4188 vec![args(&["find-generic-password", "-s", "svc", "-a", "key"])]
4189 );
4190
4191 let cli = FakeSecurityCli::new(vec![security_output(
4192 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4193 b"",
4194 b"The specified item could not be found in the keychain.\n",
4195 )]);
4196 assert!(!mac_status_via_security_cli_with(&r, &cli).unwrap().exists);
4197
4198 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4199 let err = mac_status_via_security_cli_with(&r, &cli).unwrap_err();
4200 assert_access_denied_contains(err, "auth denied");
4201 }
4202
4203 #[cfg(target_os = "macos")]
4204 #[test]
4205 fn mac_put_surfaces_add_failure_as_access_denied() {
4206 let cli = FakeSecurityCli::new(vec![security_output(
4207 51,
4208 b"",
4209 b"User interaction is not allowed.\n",
4210 )]);
4211
4212 let err =
4213 mac_write_via_security_cli("car-test-0o9-write", "key", "secret", &cli).unwrap_err();
4214 assert_access_denied_contains(err, "User interaction is not allowed.");
4215 assert_eq!(
4216 cli.calls().len(),
4217 1,
4218 "a failed write must not trigger a delete"
4219 );
4220 }
4221
4222 #[cfg(target_os = "macos")]
4228 #[test]
4229 fn mac_put_does_not_pre_delete_and_therefore_cannot_prompt() {
4230 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4231
4232 mac_put_via_security_cli_with("car-test-0o9-update-persistence", "key", "secret", &cli)
4233 .unwrap();
4234
4235 assert_eq!(
4236 cli.calls(),
4237 vec![args(&[
4238 "add-generic-password",
4239 "-U",
4240 "-A",
4241 "-s",
4242 "car-test-0o9-update-persistence",
4243 "-a",
4244 "key",
4245 "-w",
4246 "secret",
4247 ])],
4248 "an ordinary write must issue exactly one call, and not a delete"
4249 );
4250 }
4251
4252 #[cfg(target_os = "macos")]
4253 #[test]
4254 fn mac_publish_updates_in_place_without_a_pre_delete_gap() {
4255 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4256
4257 mac_publish_via_security_cli_with("svc", "key", "secret", &cli).unwrap();
4258
4259 assert_eq!(
4260 cli.calls(),
4261 vec![args(&[
4262 "add-generic-password",
4263 "-U",
4264 "-A",
4265 "-s",
4266 "svc",
4267 "-a",
4268 "key",
4269 "-w",
4270 "secret",
4271 ])]
4272 );
4273 }
4274
4275 #[cfg(target_os = "macos")]
4276 #[test]
4277 fn mac_delete_uses_security_cli_and_maps_results() {
4278 let r = SecretRef::new("svc", "key");
4279 let cli = FakeSecurityCli::new(vec![security_output(0, b"", b"")]);
4280
4281 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4282 assert_eq!(
4283 cli.calls(),
4284 vec![args(&["delete-generic-password", "-s", "svc", "-a", "key"])]
4285 );
4286
4287 let cli = FakeSecurityCli::new(vec![security_output(
4288 SECURITY_ERR_SEC_ITEM_NOT_FOUND,
4289 b"",
4290 b"The specified item could not be found in the keychain.\n",
4291 )]);
4292 mac_delete_via_security_cli_with(&r, &cli).unwrap();
4293
4294 let cli = FakeSecurityCli::new(vec![security_output(128, b"", b"auth denied\n")]);
4295 let err = mac_delete_via_security_cli_with(&r, &cli).unwrap_err();
4296 assert_access_denied_contains(err, "auth denied");
4297 }
4298}