1use std::collections::HashMap;
62use std::path::{Path, PathBuf};
63use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
64use std::time::Duration;
65
66use keyring::Entry;
67use zeroize::Zeroizing;
68
69pub const SERVICE: &str = "agentmux";
71
72const TIMEOUT: Duration = Duration::from_secs(15);
78
79#[derive(Debug, PartialEq)]
85enum RunOutcome {
86 TimedOut,
89 WorkerPanicked,
92}
93
94type ReadResult = Result<Option<Zeroizing<String>>, String>;
96
97struct SharedReads {
109 in_flight: Arc<Mutex<HashMap<String, Arc<PendingRead>>>>,
110}
111
112#[derive(Default)]
113struct PendingRead {
114 result: Mutex<Option<Result<ReadResult, ()>>>,
116 done: Condvar,
117}
118
119impl SharedReads {
120 fn new() -> Self {
121 Self { in_flight: Arc::new(Mutex::new(HashMap::new())) }
122 }
123
124 fn read(
129 &self,
130 account_id: &str,
131 timeout: Duration,
132 read: impl FnOnce() -> ReadResult + Send + 'static,
133 ) -> Result<ReadResult, RunOutcome> {
134 let spawn = |work: Box<dyn FnOnce() + Send>| {
135 std::thread::Builder::new().name("keychain-read".into()).spawn(work).map(drop)
136 };
137 self.read_with(account_id, timeout, read, spawn)
138 }
139
140 fn read_with(
143 &self,
144 account_id: &str,
145 timeout: Duration,
146 read: impl FnOnce() -> ReadResult + Send + 'static,
147 spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
148 ) -> Result<ReadResult, RunOutcome> {
149 let pending = {
150 let mut in_flight = lock(&self.in_flight);
151 match in_flight.get(account_id) {
152 Some(pending) => pending.clone(),
153 None => {
154 let pending = Arc::new(PendingRead::default());
155 in_flight.insert(account_id.to_string(), pending.clone());
156 let (worker, map, id) = (pending.clone(), self.in_flight.clone(), account_id.to_string());
157 let started = spawn(Box::new(move || {
158 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(read)).map_err(|_| ());
159 let mut in_flight = lock(&map);
163 if in_flight.get(&id).is_some_and(|p| Arc::ptr_eq(p, &worker)) {
164 in_flight.remove(&id);
165 }
166 drop(in_flight);
167 *lock(&worker.result) = Some(result);
168 worker.done.notify_all();
169 }));
170 if let Err(e) = started {
171 in_flight.remove(account_id);
174 return Ok(Err(format!("keychain read failed: could not start a thread: {e}")));
175 }
176 pending
177 }
178 }
179 };
180 let result = lock(&pending.result);
181 let (result, _) = pending
182 .done
183 .wait_timeout_while(result, timeout, |r| r.is_none())
184 .unwrap_or_else(PoisonError::into_inner);
185 match result.as_ref() {
186 None => Err(RunOutcome::TimedOut),
187 Some(Err(())) => Err(RunOutcome::WorkerPanicked),
188 Some(Ok(answer)) => Ok(answer.clone()),
189 }
190 }
191
192 fn forget(&self, account_id: &str) {
196 lock(&self.in_flight).remove(account_id);
197 }
198
199 fn after_write<T>(&self, account_id: &str, written: Result<T, String>) -> Result<T, String> {
204 if written.is_ok() {
205 self.forget(account_id);
206 }
207 written
208 }
209}
210
211fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
212 m.lock().unwrap_or_else(PoisonError::into_inner)
213}
214
215fn shared_reads() -> &'static SharedReads {
216 static READS: OnceLock<SharedReads> = OnceLock::new();
217 READS.get_or_init(SharedReads::new)
218}
219
220fn entry(account_id: &str) -> Result<Entry, String> {
221 Entry::new(SERVICE, &account_key(account_id))
222 .map_err(|e| format!("keychain entry init failed: {e}"))
223}
224
225pub fn account_key(account_id: &str) -> String {
227 format!("acct:{account_id}")
228}
229
230static FILE_STORE: OnceLock<PathBuf> = OnceLock::new();
232
233pub fn use_file_store(dir: PathBuf) -> Result<(), String> {
237 file::prepare_dir(&dir)?;
238 FILE_STORE
239 .set(dir)
240 .map_err(|_| "the secret store was already chosen".to_string())
241}
242
243pub fn backend_name() -> &'static str {
245 if FILE_STORE.get().is_some() {
246 "file"
247 } else {
248 "keychain"
249 }
250}
251
252pub fn put(account_id: &str, secret: &str) -> Result<(), String> {
259 if let Some(dir) = FILE_STORE.get() {
260 return file::put(dir, &account_key(account_id), secret);
261 }
262 let written = entry(account_id)?
263 .set_password(secret)
264 .map_err(|e| format!("keychain write failed: {e}"));
265 shared_reads().after_write(account_id, written)
266}
267
268pub fn get(account_id: &str) -> Result<Zeroizing<String>, String> {
271 if let Some(dir) = FILE_STORE.get() {
272 return file::get_optional(dir, &account_key(account_id))?
273 .ok_or_else(|| "secret store read failed: no entry for this account".to_string());
274 }
275 get_optional(account_id)?
276 .ok_or_else(|| format!("keychain read failed: {}", keyring::Error::NoEntry))
277}
278
279pub fn get_optional(account_id: &str) -> Result<Option<Zeroizing<String>>, String> {
289 if let Some(dir) = FILE_STORE.get() {
290 return file::get_optional(dir, &account_key(account_id));
291 }
292 let id = account_id.to_string();
293 match shared_reads().read(account_id, TIMEOUT, move || get_optional_now(&id)) {
294 Ok(result) => result,
295 Err(outcome) => Err(run_outcome_message("read", outcome)),
296 }
297}
298
299fn get_optional_now(account_id: &str) -> Result<Option<Zeroizing<String>>, String> {
300 match entry(account_id)?.get_password() {
301 Ok(pw) => Ok(Some(Zeroizing::new(pw))),
302 Err(keyring::Error::NoEntry) => Ok(None),
303 Err(e) => Err(format!("keychain read failed: {e}")),
304 }
305}
306
307pub fn delete(account_id: &str) -> Result<(), String> {
313 if let Some(dir) = FILE_STORE.get() {
314 return file::delete(dir, &account_key(account_id));
315 }
316 let deleted = match entry(account_id)?.delete_password() {
317 Ok(()) => Ok(()),
318 Err(keyring::Error::NoEntry) => Ok(()),
319 Err(e) => Err(format!("keychain delete failed: {e}")),
320 };
321 shared_reads().after_write(account_id, deleted)
322}
323
324fn run_outcome_message(op: &str, outcome: RunOutcome) -> String {
325 match outcome {
326 RunOutcome::TimedOut => format!(
327 "keychain {op} timed out after {TIMEOUT:?} — likely an unanswered OS access-consent \
328 prompt (see docs/retro/retro-macos-muxbus-keychain-prompt-storm-2026-08-19.md §5)"
329 ),
330 RunOutcome::WorkerPanicked => {
331 format!("keychain {op} failed: the background thread performing it panicked")
332 }
333 }
334}
335
336mod file {
341 use super::*;
342 use sha2::{Digest, Sha256};
343 use std::io::Write;
344
345 pub(super) fn path_for(dir: &Path, key: &str) -> PathBuf {
346 let digest = Sha256::digest(key.as_bytes());
347 dir.join(digest.iter().map(|b| format!("{b:02x}")).collect::<String>())
348 }
349
350 pub(super) fn prepare_dir(dir: &Path) -> Result<(), String> {
353 let mut builder = std::fs::DirBuilder::new();
354 builder.recursive(true);
355 #[cfg(unix)]
356 {
357 use std::os::unix::fs::DirBuilderExt;
358 builder.mode(0o700);
359 }
360 builder
361 .create(dir)
362 .map_err(|e| format!("secret store: cannot create {}: {e}", dir.display()))?;
363 #[cfg(unix)]
364 {
365 use std::os::unix::fs::PermissionsExt;
366 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
367 .map_err(|e| format!("secret store: cannot restrict {}: {e}", dir.display()))?;
368 }
369 Ok(())
370 }
371
372 pub(super) fn put(dir: &Path, key: &str, secret: &str) -> Result<(), String> {
375 let path = path_for(dir, key);
376 let tmp = dir.join(format!(".tmp-{}", uuid::Uuid::new_v4().simple()));
377 let mut opts = std::fs::OpenOptions::new();
378 opts.write(true).create_new(true);
379 #[cfg(unix)]
380 {
381 use std::os::unix::fs::OpenOptionsExt;
382 opts.mode(0o600);
383 }
384 let written = opts
385 .open(&tmp)
386 .and_then(|mut f| f.write_all(secret.as_bytes()).and_then(|()| f.sync_all()))
387 .and_then(|()| std::fs::rename(&tmp, &path));
388 written.map_err(|e| {
389 let _ = std::fs::remove_file(&tmp);
390 format!("secret store write failed: {e}")
391 })
392 }
393
394 pub(super) fn get_optional(dir: &Path, key: &str) -> Result<Option<Zeroizing<String>>, String> {
395 match std::fs::read(path_for(dir, key)) {
396 Ok(bytes) => String::from_utf8(bytes)
397 .map(|s| Some(Zeroizing::new(s)))
398 .map_err(|_| "secret store read failed: the entry is not valid UTF-8".to_string()),
399 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
400 Err(e) => Err(format!("secret store read failed: {e}")),
401 }
402 }
403
404 pub(super) fn delete(dir: &Path, key: &str) -> Result<(), String> {
405 match std::fs::remove_file(path_for(dir, key)) {
406 Ok(()) => Ok(()),
407 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
408 Err(e) => Err(format!("secret store delete failed: {e}")),
409 }
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn file_store_round_trips_overwrites_and_deletes() {
419 let tmp = tempfile::tempdir().unwrap();
420 let dir = tmp.path().join("secrets");
421 file::prepare_dir(&dir).unwrap();
422 let key = account_key("abc");
423 assert!(file::get_optional(&dir, &key).unwrap().is_none());
424 file::put(&dir, &key, "first").unwrap();
425 assert_eq!(file::get_optional(&dir, &key).unwrap().as_deref().map(String::as_str), Some("first"));
426 file::put(&dir, &key, "second").unwrap();
427 assert_eq!(file::get_optional(&dir, &key).unwrap().as_deref().map(String::as_str), Some("second"));
428 file::delete(&dir, &key).unwrap();
429 assert!(file::get_optional(&dir, &key).unwrap().is_none());
430 file::delete(&dir, &key).unwrap();
432 file::put(&dir, &key, "x").unwrap();
434 let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
435 assert_eq!(names.len(), 1, "{names:?}");
436 }
437
438 #[test]
439 fn file_store_entries_are_separate_and_named_by_hash() {
440 let tmp = tempfile::tempdir().unwrap();
441 let dir = tmp.path().to_path_buf();
442 let (a, b) = ("muxbus:global", "browser-auth:id:../../etc/passwd");
444 file::put(&dir, a, "A").unwrap();
445 file::put(&dir, b, "B").unwrap();
446 assert_eq!(file::get_optional(&dir, a).unwrap().as_deref().map(String::as_str), Some("A"));
447 assert_eq!(file::get_optional(&dir, b).unwrap().as_deref().map(String::as_str), Some("B"));
448 for key in [a, b] {
449 let p = file::path_for(&dir, key);
450 assert_eq!(p.parent(), Some(dir.as_path()));
451 let name = p.file_name().unwrap().to_str().unwrap().to_string();
452 assert_eq!(name.len(), 64);
453 assert!(name.bytes().all(|c| c.is_ascii_hexdigit()));
454 }
455 }
456
457 #[cfg(unix)]
458 #[test]
459 fn file_store_is_owner_only() {
460 use std::os::unix::fs::PermissionsExt;
461 let tmp = tempfile::tempdir().unwrap();
462 let dir = tmp.path().join("secrets");
463 std::fs::create_dir(&dir).unwrap();
464 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
465 file::prepare_dir(&dir).unwrap();
467 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
468 assert_eq!(mode(&dir), 0o700);
469 file::put(&dir, "k", "v").unwrap();
470 assert_eq!(mode(&file::path_for(&dir, "k")), 0o600);
471 }
472
473 #[test]
474 fn account_key_is_namespaced() {
475 assert_eq!(account_key("abc123"), "acct:abc123");
476 }
477
478 const SHORT: Duration = Duration::from_millis(50);
479 const LONG: Duration = Duration::from_secs(10);
480
481 struct Gate {
484 calls: std::sync::atomic::AtomicUsize,
485 open: Mutex<bool>,
486 opened: Condvar,
487 }
488
489 impl Gate {
490 fn new() -> Arc<Self> {
491 Arc::new(Self { calls: Default::default(), open: Mutex::new(false), opened: Condvar::new() })
492 }
493 fn read(self: &Arc<Self>, value: &'static str) -> impl FnOnce() -> ReadResult + Send + 'static {
494 let gate = self.clone();
495 move || {
496 gate.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
497 drop(gate.opened.wait_while(lock(&gate.open), |open| !*open).unwrap());
498 Ok(Some(Zeroizing::new(value.to_string())))
499 }
500 }
501 fn open(&self) {
502 *lock(&self.open) = true;
503 self.opened.notify_all();
504 }
505 fn calls(&self) -> usize {
506 self.calls.load(std::sync::atomic::Ordering::SeqCst)
507 }
508 }
509
510 fn secret(value: &str) -> ReadResult {
511 Ok(Some(Zeroizing::new(value.to_string())))
512 }
513
514 #[test]
515 fn a_read_that_finishes_in_time_returns_its_value() {
516 let reads = SharedReads::new();
517 assert_eq!(reads.read("a", LONG, || secret("v")), Ok(secret("v")));
518 assert_eq!(reads.read("b", LONG, || Ok(None)), Ok(Ok(None)));
519 }
520
521 #[test]
522 fn retries_behind_an_unanswered_read_share_it_instead_of_queueing_more() {
523 let reads = SharedReads::new();
526 let gate = Gate::new();
527 for _ in 0..5 {
528 assert_eq!(reads.read("muxbus", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
529 }
530 std::thread::sleep(SHORT);
531 assert_eq!(gate.calls(), 1, "each retry started its own platform read");
532 gate.open();
533 }
534
535 #[test]
536 fn callers_that_join_a_read_all_get_its_answer() {
537 let reads = Arc::new(SharedReads::new());
538 let gate = Gate::new();
539 let waiters: Vec<_> = (0..3)
540 .map(|_| {
541 let (reads, read) = (reads.clone(), gate.read("secret"));
542 std::thread::spawn(move || reads.read("acct", LONG, read))
543 })
544 .collect();
545 std::thread::sleep(Duration::from_millis(300)); gate.open();
547 for waiter in waiters {
548 assert_eq!(waiter.join().unwrap(), Ok(secret("secret")));
549 }
550 assert_eq!(gate.calls(), 1);
551 }
552
553 #[test]
554 fn a_read_after_the_answer_reads_again() {
555 let reads = SharedReads::new();
557 let gate = Gate::new();
558 gate.open();
559 assert_eq!(reads.read("acct", LONG, gate.read("v")), Ok(secret("v")));
560 assert_eq!(reads.read("acct", LONG, gate.read("v")), Ok(secret("v")));
561 assert_eq!(gate.calls(), 2);
562 }
563
564 #[test]
565 fn accounts_are_read_separately() {
566 let reads = SharedReads::new();
567 let gate = Gate::new();
568 assert_eq!(reads.read("a", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
569 assert_eq!(reads.read("b", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
570 std::thread::sleep(SHORT);
571 assert_eq!(gate.calls(), 2);
572 gate.open();
573 }
574
575 #[test]
576 fn after_forget_the_next_read_starts_its_own_and_later_ones_share_that() {
577 let reads = SharedReads::new();
578 let (old, new) = (Gate::new(), Gate::new());
579 assert_eq!(reads.read("acct", SHORT, old.read("before")), Err(RunOutcome::TimedOut));
580 reads.forget("acct"); assert_eq!(reads.read("acct", SHORT, new.read("after")), Err(RunOutcome::TimedOut));
582 old.open();
584 std::thread::sleep(SHORT);
585 assert_eq!(reads.read("acct", SHORT, new.read("after")), Err(RunOutcome::TimedOut));
586 std::thread::sleep(SHORT);
587 assert_eq!((old.calls(), new.calls()), (1, 1));
588 new.open();
589 }
590
591 #[test]
592 fn only_a_write_that_succeeded_stops_sharing_the_waiting_read() {
593 let reads = SharedReads::new();
594 let gate = Gate::new();
595 assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
596 let _ = reads.after_write("acct", Err::<(), _>("keychain write failed".to_string()));
598 assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
599 std::thread::sleep(SHORT);
600 assert_eq!(gate.calls(), 1, "a failed write let a retry start a second read");
601 let _ = reads.after_write("acct", Ok(()));
603 assert_eq!(reads.read("acct", SHORT, gate.read("v")), Err(RunOutcome::TimedOut));
604 std::thread::sleep(SHORT);
605 assert_eq!(gate.calls(), 2);
606 gate.open();
607 }
608
609 #[test]
610 fn a_read_whose_thread_cannot_start_fails_and_does_not_block_the_next() {
611 let reads = SharedReads::new();
612 let refuse = |_: Box<dyn FnOnce() + Send>| Err(std::io::Error::other("no threads left"));
613 let failed = reads.read_with("acct", LONG, || Ok(None), refuse).unwrap();
614 assert!(failed.unwrap_err().contains("no threads left"));
615 assert_eq!(reads.read("acct", LONG, || secret("v")), Ok(secret("v")));
617 }
618
619 #[test]
620 fn a_panicking_read_is_reported_as_such_and_the_next_read_tries_again() {
621 let reads = SharedReads::new();
624 assert_eq!(
625 reads.read("acct", LONG, || panic!("simulated worker panic")),
626 Err(RunOutcome::WorkerPanicked)
627 );
628 assert_eq!(reads.read("acct", LONG, || Ok(None)), Ok(Ok(None)));
629 }
630
631 #[test]
632 fn run_outcome_message_distinguishes_timeout_from_panic() {
633 assert!(run_outcome_message("read", RunOutcome::TimedOut).contains("timed out"));
634 assert!(run_outcome_message("read", RunOutcome::WorkerPanicked).contains("panicked"));
635 }
636}