1use anyhow::Result;
2use serde::{Serialize, de::DeserializeOwned};
3use serde_json::to_string_pretty;
4use std::fs::{self, File};
5use std::io::{self, Read, Write};
6use std::marker::PhantomData;
7#[cfg(unix)]
8use std::os::unix::fs::PermissionsExt;
9
10pub trait AuthStorage<T>: Send + Sync
11where
12 T: Serialize + DeserializeOwned + Send + Sync + Clone,
13{
14 fn load(&self) -> Result<Option<T>>;
15 fn save(&self, value: T) -> Result<()>;
16 fn clear(&self) -> Result<()>;
17 fn path(&self) -> String;
18}
19
20pub trait Keychain: Send + Sync {
21 fn read(&self, service: &str, account: &str) -> Result<Option<String>>;
22 fn write(&self, service: &str, account: &str, value: &str) -> Result<()>;
23 fn delete(&self, service: &str, account: &str) -> Result<()>;
24}
25
26#[derive(Default)]
27pub struct StubKeychain;
28
29impl Keychain for StubKeychain {
30 fn read(&self, _service: &str, _account: &str) -> Result<Option<String>> {
31 Ok(None)
32 }
33
34 fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
35 Ok(())
36 }
37
38 fn delete(&self, _service: &str, _account: &str) -> Result<()> {
39 Ok(())
40 }
41}
42
43#[derive(Default, Clone, Copy)]
44pub struct SystemKeychain;
45
46#[cfg(target_os = "macos")]
47impl Keychain for SystemKeychain {
48 fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
49 let output = run_security(&["find-generic-password", "-s", service, "-a", account, "-w"])?;
50 if output.status.success() {
51 let mut raw = String::from_utf8(output.stdout)
52 .map_err(|err| anyhow::anyhow!("Keychain value is not valid UTF-8: {err}"))?;
53 trim_one_trailing_newline(&mut raw);
54 return Ok(Some(raw));
55 }
56 let stderr = String::from_utf8_lossy(&output.stderr);
57 if stderr.contains("could not be found") || stderr.contains("specified item could not") {
58 return Ok(None);
59 }
60 Err(anyhow::anyhow!("Keychain read failed: {}", stderr.trim()))
61 }
62
63 fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
64 anyhow::bail!("Keychain write is not available through non-interactive compatibility mode")
65 }
66
67 fn delete(&self, service: &str, account: &str) -> Result<()> {
68 let output = run_security(&["delete-generic-password", "-s", service, "-a", account])?;
69 if output.status.success() {
70 return Ok(());
71 }
72 let stderr = String::from_utf8_lossy(&output.stderr);
73 if stderr.contains("could not be found") || stderr.contains("specified item could not") {
74 return Ok(());
75 }
76 Err(anyhow::anyhow!("Keychain delete failed: {}", stderr.trim()))
77 }
78}
79
80#[cfg(target_os = "macos")]
81fn run_security(args: &[&str]) -> Result<std::process::Output> {
82 use std::process::{Command, Stdio};
83 use std::time::{Duration, Instant};
84
85 let mut child = Command::new("/usr/bin/security")
86 .args(args)
87 .stdin(Stdio::null())
88 .stdout(Stdio::piped())
89 .stderr(Stdio::piped())
90 .spawn()
91 .map_err(|err| anyhow::anyhow!("Failed to start /usr/bin/security: {err}"))?;
92 let start = Instant::now();
93 loop {
94 if child
95 .try_wait()
96 .map_err(|err| anyhow::anyhow!("Failed waiting for /usr/bin/security: {err}"))?
97 .is_some()
98 {
99 return child.wait_with_output().map_err(|err| {
100 anyhow::anyhow!("Failed collecting /usr/bin/security output: {err}")
101 });
102 }
103 if start.elapsed() >= Duration::from_secs(10) {
104 let _ = child.kill();
105 let _ = child.wait();
106 anyhow::bail!("Timed out reading macOS Keychain");
107 }
108 std::thread::sleep(Duration::from_millis(25));
109 }
110}
111
112#[cfg(target_os = "macos")]
113fn trim_one_trailing_newline(value: &mut String) {
114 if value.ends_with('\n') {
115 value.pop();
116 if value.ends_with('\r') {
117 value.pop();
118 }
119 }
120}
121
122#[cfg(not(target_os = "macos"))]
123impl Keychain for SystemKeychain {
124 fn read(&self, _service: &str, _account: &str) -> Result<Option<String>> {
125 Ok(None)
126 }
127
128 fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
129 anyhow::bail!("Keychain storage is not available on this platform")
130 }
131
132 fn delete(&self, _service: &str, _account: &str) -> Result<()> {
133 Ok(())
134 }
135}
136
137pub struct FileAuthStore<T>
138where
139 T: Serialize + DeserializeOwned + Send + Sync + Clone,
140{
141 file: String,
142 legacy_file: String,
143 _marker: std::marker::PhantomData<T>,
144}
145
146impl<T> FileAuthStore<T>
147where
148 T: Serialize + DeserializeOwned + Send + Sync + Clone,
149{
150 pub fn new(file: String, legacy_file: String) -> Self {
151 Self {
152 file,
153 legacy_file,
154 _marker: Default::default(),
155 }
156 }
157}
158
159impl<T> AuthStorage<T> for FileAuthStore<T>
160where
161 T: Serialize + DeserializeOwned + Send + Sync + Clone,
162{
163 fn load(&self) -> Result<Option<T>> {
164 let parsed = load_auth_file::<T>(&self.file);
165 if parsed.is_some() {
166 return Ok(parsed);
167 }
168 if self.file == self.legacy_file {
169 return Ok(None);
170 }
171 Ok(load_auth_file::<T>(&self.legacy_file))
172 }
173
174 fn save(&self, value: T) -> Result<()> {
175 let path = std::path::Path::new(&self.file);
176 if let Some(dir) = path.parent() {
177 fs::create_dir_all(dir)?;
178 set_mode(dir, 0o700);
179 }
180 write_atomically(&self.file, &value)
181 }
182
183 fn clear(&self) -> Result<()> {
184 for path in [&self.file, &self.legacy_file] {
185 if let Err(err) = fs::remove_file(path)
186 && err.kind() != io::ErrorKind::NotFound
187 {
188 return Err(anyhow::Error::from(err));
189 }
190 }
191 Ok(())
192 }
193
194 fn path(&self) -> String {
195 self.file.clone()
196 }
197}
198
199pub struct KeychainFileAuthStore<T, K = SystemKeychain>
200where
201 T: Serialize + DeserializeOwned + Send + Sync + Clone,
202 K: Keychain,
203{
204 file_store: FileAuthStore<T>,
205 keychain: K,
206 service: String,
207 account: String,
208 use_keychain: bool,
209 keychain_path: String,
210 _marker: PhantomData<T>,
211}
212
213impl<T, K> KeychainFileAuthStore<T, K>
214where
215 T: Serialize + DeserializeOwned + Send + Sync + Clone,
216 K: Keychain,
217{
218 pub fn new(
219 file: String,
220 legacy_file: String,
221 service: impl Into<String>,
222 account: impl Into<String>,
223 use_keychain: bool,
224 keychain: K,
225 ) -> Self {
226 Self {
227 file_store: FileAuthStore::new(file, legacy_file),
228 keychain,
229 service: service.into(),
230 account: account.into(),
231 use_keychain,
232 keychain_path: "macOS Keychain".to_string(),
233 _marker: PhantomData,
234 }
235 }
236}
237
238impl<T, K> AuthStorage<T> for KeychainFileAuthStore<T, K>
239where
240 T: Serialize + DeserializeOwned + Send + Sync + Clone,
241 K: Keychain,
242{
243 fn load(&self) -> Result<Option<T>> {
244 if let Some(parsed) = self.file_store.load()? {
245 return Ok(Some(parsed));
246 }
247 if self.use_keychain
248 && let Some(raw) = self.keychain.read(&self.service, &self.account)?
249 {
250 return serde_json::from_str::<T>(&raw)
251 .map(Some)
252 .map_err(|err| anyhow::anyhow!("Failed to parse Keychain auth JSON: {err}"));
253 }
254 Ok(None)
255 }
256
257 fn save(&self, value: T) -> Result<()> {
258 if self.use_keychain {
259 let raw = serde_json::to_string(&value)?;
260 if self
261 .keychain
262 .write(&self.service, &self.account, &raw)
263 .is_ok()
264 {
265 return Ok(());
266 }
267 return self.file_store.save(value);
268 }
269 self.file_store.save(value)
270 }
271
272 fn clear(&self) -> Result<()> {
273 if self.use_keychain {
274 self.keychain.delete(&self.service, &self.account)?;
275 }
276 self.file_store.clear()
277 }
278
279 fn path(&self) -> String {
280 if self.use_keychain {
281 self.keychain_path.clone()
282 } else {
283 self.file_store.path()
284 }
285 }
286}
287
288pub fn load_auth_file<T: DeserializeOwned>(path: &str) -> Option<T> {
289 let mut file = File::open(path).ok()?;
290 let mut raw = String::new();
291 file.read_to_string(&mut raw).ok()?;
292 serde_json::from_str::<T>(&raw).ok()
293}
294
295pub fn load_auth_file_value(path: &std::path::Path) -> Option<serde_json::Value> {
296 let mut file = File::open(path).ok()?;
297 let mut raw = String::new();
298 file.read_to_string(&mut raw).ok()?;
299 serde_json::from_str::<serde_json::Value>(&raw).ok()
300}
301
302pub fn load_auth_file_with_legacy<T: DeserializeOwned>(
303 primary: &std::path::Path,
304 legacy: &std::path::Path,
305) -> Option<T> {
306 if let Some(value) = load_auth_file_value(primary) {
307 return serde_json::from_value(value).ok();
308 }
309 if primary == legacy {
310 None
311 } else {
312 load_auth_file_value(legacy).and_then(|value| serde_json::from_value(value).ok())
313 }
314}
315
316pub fn delete_auth_file(primary: &std::path::Path, legacy: &std::path::Path) -> io::Result<()> {
317 if let Err(err) = fs::remove_file(primary)
318 && err.kind() != io::ErrorKind::NotFound
319 {
320 return Err(err);
321 }
322 if primary != legacy
323 && let Err(err) = fs::remove_file(legacy)
324 && err.kind() != io::ErrorKind::NotFound
325 {
326 return Err(err);
327 }
328 Ok(())
329}
330
331pub fn write_atomically<T: Serialize>(path: &str, value: &T) -> Result<()> {
332 let dir = std::path::Path::new(path)
333 .parent()
334 .ok_or_else(|| anyhow::anyhow!("invalid auth path"))?;
335 fs::create_dir_all(dir)?;
336 set_mode(dir, 0o700);
337
338 let tmp = format!("{path}.tmp-{}", uuid::Uuid::new_v4());
339 #[cfg(unix)]
340 let mut out = {
341 use std::os::unix::fs::OpenOptionsExt;
342 std::fs::OpenOptions::new()
343 .write(true)
344 .create_new(true)
345 .mode(0o600)
346 .open(&tmp)?
347 };
348 #[cfg(not(unix))]
349 let mut out = std::fs::OpenOptions::new()
350 .write(true)
351 .create_new(true)
352 .open(&tmp)?;
353 out.write_all(to_string_pretty(value)?.as_bytes())?;
354 out.sync_all()?;
355 if let Err(err) = fs::rename(&tmp, path) {
356 let _ = fs::remove_file(&tmp);
357 return Err(err.into());
358 }
359 set_mode(std::path::Path::new(path), 0o600);
360 Ok(())
361}
362
363fn set_mode(path: &std::path::Path, mode: u32) {
364 #[cfg(unix)]
365 {
366 if let Ok(meta) = fs::metadata(path) {
367 let mut permissions = meta.permissions();
368 permissions.set_mode(mode);
369 let _ = fs::set_permissions(path, permissions);
370 }
371 }
372}
373
374pub struct InMemoryAuthStore<T>
375where
376 T: Serialize + DeserializeOwned + Send + Sync + Clone,
377{
378 inner: std::sync::Arc<std::sync::Mutex<Option<T>>>,
379}
380
381impl<T> Default for InMemoryAuthStore<T>
382where
383 T: Serialize + DeserializeOwned + Send + Sync + Clone,
384{
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390impl<T> InMemoryAuthStore<T>
391where
392 T: Serialize + DeserializeOwned + Send + Sync + Clone,
393{
394 pub fn new() -> Self {
395 Self {
396 inner: std::sync::Arc::new(std::sync::Mutex::new(None)),
397 }
398 }
399}
400
401impl<T> Clone for InMemoryAuthStore<T>
402where
403 T: Serialize + DeserializeOwned + Send + Sync + Clone,
404{
405 fn clone(&self) -> Self {
406 Self {
407 inner: self.inner.clone(),
408 }
409 }
410}
411
412impl<T> AuthStorage<T> for InMemoryAuthStore<T>
413where
414 T: Serialize + DeserializeOwned + Send + Sync + Clone,
415{
416 fn load(&self) -> Result<Option<T>> {
417 let inner = self
418 .inner
419 .lock()
420 .map_err(|err| anyhow::anyhow!(err.to_string()))?;
421 Ok(inner.clone())
422 }
423
424 fn save(&self, value: T) -> Result<()> {
425 let mut inner = self
426 .inner
427 .lock()
428 .map_err(|err| anyhow::anyhow!(err.to_string()))?;
429 *inner = Some(value);
430 Ok(())
431 }
432
433 fn clear(&self) -> Result<()> {
434 let mut inner = self
435 .inner
436 .lock()
437 .map_err(|err| anyhow::anyhow!(err.to_string()))?;
438 *inner = None;
439 Ok(())
440 }
441
442 fn path(&self) -> String {
443 "memory".to_string()
444 }
445}
446
447#[cfg(test)]
448pub fn fixture_store<T>() -> InMemoryAuthStore<T>
449where
450 T: Serialize + serde::de::DeserializeOwned + Send + Sync + Clone,
451{
452 InMemoryAuthStore::new()
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use serde_json::json;
459 use std::collections::HashMap;
460 use std::sync::{Arc, Mutex};
461
462 #[derive(Clone, Default)]
463 struct MockKeychain {
464 values: Arc<Mutex<HashMap<(String, String), String>>>,
465 }
466
467 impl MockKeychain {
468 fn set_raw(&self, service: &str, account: &str, value: serde_json::Value) {
469 self.values.lock().unwrap().insert(
470 (service.to_string(), account.to_string()),
471 value.to_string(),
472 );
473 }
474
475 fn raw(&self, service: &str, account: &str) -> Option<String> {
476 self.values
477 .lock()
478 .unwrap()
479 .get(&(service.to_string(), account.to_string()))
480 .cloned()
481 }
482 }
483
484 impl Keychain for MockKeychain {
485 fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
486 Ok(self.raw(service, account))
487 }
488
489 fn write(&self, service: &str, account: &str, value: &str) -> Result<()> {
490 self.values.lock().unwrap().insert(
491 (service.to_string(), account.to_string()),
492 value.to_string(),
493 );
494 Ok(())
495 }
496
497 fn delete(&self, service: &str, account: &str) -> Result<()> {
498 self.values
499 .lock()
500 .unwrap()
501 .remove(&(service.to_string(), account.to_string()));
502 Ok(())
503 }
504 }
505
506 #[derive(Clone, Default)]
507 struct ReadOnlyKeychain(MockKeychain);
508
509 impl Keychain for ReadOnlyKeychain {
510 fn read(&self, service: &str, account: &str) -> Result<Option<String>> {
511 self.0.read(service, account)
512 }
513
514 fn write(&self, _service: &str, _account: &str, _value: &str) -> Result<()> {
515 anyhow::bail!("read-only")
516 }
517
518 fn delete(&self, service: &str, account: &str) -> Result<()> {
519 self.0.delete(service, account)
520 }
521 }
522
523 fn temp_auth_path(dir: &tempfile::TempDir, name: &str) -> String {
524 dir.path().join(name).to_string_lossy().to_string()
525 }
526
527 #[test]
528 fn keychain_file_store_loads_file_before_keychain() {
529 let temp = tempfile::TempDir::new().unwrap();
530 let file = temp_auth_path(&temp, "auth.json");
531 let legacy = temp_auth_path(&temp, "legacy.json");
532 write_atomically(&file, &json!({"source": "file"})).unwrap();
533
534 let keychain = MockKeychain::default();
535 keychain.set_raw("svc", "acct", json!({"source": "keychain"}));
536
537 let store: KeychainFileAuthStore<serde_json::Value, _> =
538 KeychainFileAuthStore::new(file, legacy, "svc", "acct", true, keychain);
539
540 let loaded = store.load().unwrap().unwrap();
541 assert_eq!(loaded["source"], json!("file"));
542 assert_eq!(store.path(), "macOS Keychain");
543 }
544
545 #[test]
546 fn keychain_file_store_falls_back_to_keychain_when_file_missing() {
547 let temp = tempfile::TempDir::new().unwrap();
548 let file = temp_auth_path(&temp, "auth.json");
549 let legacy = temp_auth_path(&temp, "legacy.json");
550 let keychain = MockKeychain::default();
551 keychain.set_raw("svc", "acct", json!({"source": "keychain"}));
552
553 let store: KeychainFileAuthStore<serde_json::Value, _> =
554 KeychainFileAuthStore::new(file, legacy, "svc", "acct", true, keychain);
555
556 let loaded = store.load().unwrap().unwrap();
557 assert_eq!(loaded["source"], json!("keychain"));
558 }
559
560 #[test]
561 fn keychain_file_store_saves_and_clears_keychain_when_enabled() {
562 let temp = tempfile::TempDir::new().unwrap();
563 let file = temp_auth_path(&temp, "auth.json");
564 let legacy = temp_auth_path(&temp, "legacy.json");
565 write_atomically(&file, &json!({"source": "file"})).unwrap();
566
567 let keychain = MockKeychain::default();
568 let store: KeychainFileAuthStore<serde_json::Value, _> =
569 KeychainFileAuthStore::new(file.clone(), legacy, "svc", "acct", true, keychain.clone());
570
571 store.save(json!({"source": "saved"})).unwrap();
572 let raw = keychain.raw("svc", "acct").unwrap();
573 assert_eq!(
574 serde_json::from_str::<serde_json::Value>(&raw).unwrap()["source"],
575 json!("saved")
576 );
577
578 store.clear().unwrap();
579 assert!(keychain.raw("svc", "acct").is_none());
580 assert!(!std::path::Path::new(&file).exists());
581 }
582
583 #[test]
584 fn keychain_file_store_falls_back_to_file_when_keychain_write_fails() {
585 let temp = tempfile::TempDir::new().unwrap();
586 let file = temp_auth_path(&temp, "auth.json");
587 let legacy = temp_auth_path(&temp, "legacy.json");
588 let store: KeychainFileAuthStore<serde_json::Value, _> = KeychainFileAuthStore::new(
589 file.clone(),
590 legacy,
591 "svc",
592 "acct",
593 true,
594 ReadOnlyKeychain::default(),
595 );
596
597 store.save(json!({"source": "file-fallback"})).unwrap();
598 assert_eq!(
599 store.load().unwrap().unwrap()["source"],
600 json!("file-fallback")
601 );
602 assert!(std::path::Path::new(&file).exists());
603 }
604
605 #[test]
606 fn keychain_file_store_uses_file_when_keychain_disabled() {
607 let temp = tempfile::TempDir::new().unwrap();
608 let file = temp_auth_path(&temp, "auth.json");
609 let legacy = temp_auth_path(&temp, "legacy.json");
610 let keychain = MockKeychain::default();
611 let store: KeychainFileAuthStore<serde_json::Value, _> = KeychainFileAuthStore::new(
612 file.clone(),
613 legacy,
614 "svc",
615 "acct",
616 false,
617 keychain.clone(),
618 );
619
620 store.save(json!({"source": "file"})).unwrap();
621 assert!(keychain.raw("svc", "acct").is_none());
622 assert_eq!(store.path(), file);
623 assert_eq!(store.load().unwrap().unwrap()["source"], json!("file"));
624 }
625}