1use secrecy::SecretString;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum SecretRef {
17 Env(String),
18 Keychain { service: String, account: String },
19 File(PathBuf),
20 Cmd(String),
21}
22
23#[derive(thiserror::Error, Debug)]
24pub enum SecretError {
25 #[error("env var {0} not set")]
26 EnvNotSet(String),
27 #[error("keychain item not found: {service}/{account}")]
28 KeychainNotFound { service: String, account: String },
29 #[error("keychain backend error: {0}")]
30 KeychainBackend(String),
31 #[error("read file {path}: {source}")]
32 FileRead {
33 path: String,
34 #[source]
35 source: std::io::Error,
36 },
37 #[error("file mode is not 0600: {0}")]
38 FileMode(String),
39 #[error("decrypt {0}")]
40 AgeDecrypt(String),
41 #[error("cmd {cmd} exited with {status}")]
42 Cmd { cmd: String, status: i32 },
43 #[error("invalid SecretRef syntax: {0}")]
44 Parse(String),
45}
46
47impl std::fmt::Display for SecretRef {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 match self {
50 SecretRef::Env(v) => write!(f, "env:{v}"),
51 SecretRef::Keychain { service, account } => {
52 write!(f, "keychain:{service}/{account}")
53 }
54 SecretRef::File(p) => write!(f, "file:{}", p.display()),
55 SecretRef::Cmd(c) => write!(f, "cmd:{c}"),
56 }
57 }
58}
59
60impl std::str::FromStr for SecretRef {
61 type Err = SecretError;
62 fn from_str(s: &str) -> Result<Self, Self::Err> {
63 let (scheme, rest) = s
64 .split_once(':')
65 .ok_or_else(|| SecretError::Parse(format!("missing scheme: {s}")))?;
66 match scheme {
67 "env" => Ok(SecretRef::Env(rest.to_string())),
68 "keychain" => {
69 let (service, account) = rest.split_once('/').ok_or_else(|| {
70 SecretError::Parse(format!("keychain ref needs service/account: {s}"))
71 })?;
72 Ok(SecretRef::Keychain {
73 service: service.to_string(),
74 account: account.to_string(),
75 })
76 }
77 "file" => Ok(SecretRef::File(PathBuf::from(rest))),
78 "cmd" => Ok(SecretRef::Cmd(rest.to_string())),
79 other => Err(SecretError::Parse(format!("unknown scheme: {other}"))),
80 }
81 }
82}
83
84impl Serialize for SecretRef {
85 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
86 s.collect_str(self)
87 }
88}
89
90impl<'de> Deserialize<'de> for SecretRef {
91 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
92 let s = String::deserialize(d)?;
93 s.parse().map_err(serde::de::Error::custom)
94 }
95}
96
97impl SecretRef {
98 pub async fn resolve(&self) -> Result<SecretString, SecretError> {
99 match self {
100 SecretRef::Env(var) => std::env::var(var)
101 .map(SecretString::from)
102 .map_err(|_| SecretError::EnvNotSet(var.clone())),
103 SecretRef::Keychain { service, account } => {
104 let svc = service.clone();
105 let acct = account.clone();
106 let res = tokio::task::spawn_blocking(move || -> Result<String, SecretError> {
107 let entry = keyring::Entry::new(&svc, &acct)
108 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
109 match entry.get_password() {
110 Ok(s) => Ok(s),
111 Err(keyring::Error::NoEntry) => Err(SecretError::KeychainNotFound {
112 service: svc.clone(),
113 account: acct.clone(),
114 }),
115 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
116 }
117 })
118 .await
119 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?;
120 res.map(SecretString::from)
121 }
122 SecretRef::File(path) => resolve_file(path).await,
123 SecretRef::Cmd(spec) => resolve_cmd(spec).await,
124 }
125 }
126
127 pub async fn check(&self) -> bool {
131 self.resolve().await.is_ok()
132 }
133
134 pub async fn resolve_to_string(&self) -> Option<String> {
140 use secrecy::ExposeSecret;
141 self.resolve()
142 .await
143 .ok()
144 .map(|s| s.expose_secret().to_string())
145 }
146}
147
148pub async fn keychain_get(
159 service: &str,
160 account: &str,
161) -> Result<Option<SecretString>, SecretError> {
162 let svc = service.to_string();
163 let acct = account.to_string();
164 tokio::task::spawn_blocking(move || -> Result<Option<String>, SecretError> {
165 let entry = keyring::Entry::new(&svc, &acct)
166 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
167 match entry.get_password() {
168 Ok(s) => Ok(Some(s)),
169 Err(keyring::Error::NoEntry) => Ok(None),
170 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
171 }
172 })
173 .await
174 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
175 .map(|opt| opt.map(SecretString::from))
176}
177
178pub async fn keychain_set(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
181 let svc = service.to_string();
182 let acct = account.to_string();
183 let val = value.to_string();
184 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
185 let entry = keyring::Entry::new(&svc, &acct)
186 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
187 entry
188 .set_password(&val)
189 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
190 Ok(())
191 })
192 .await
193 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
194}
195
196pub async fn keychain_delete(service: &str, account: &str) -> Result<(), SecretError> {
199 let svc = service.to_string();
200 let acct = account.to_string();
201 tokio::task::spawn_blocking(move || -> Result<(), SecretError> {
202 let entry = keyring::Entry::new(&svc, &acct)
203 .map_err(|e| SecretError::KeychainBackend(e.to_string()))?;
204 match entry.delete_credential() {
205 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
206 Err(e) => Err(SecretError::KeychainBackend(e.to_string())),
207 }
208 })
209 .await
210 .map_err(|e| SecretError::KeychainBackend(format!("join: {e}")))?
211}
212
213async fn resolve_cmd(spec: &str) -> Result<SecretString, SecretError> {
214 let mut parts = shell_words::split(spec)
215 .map_err(|e| SecretError::Parse(format!("split cmd {spec:?}: {e}")))?;
216 if parts.is_empty() {
217 return Err(SecretError::Parse("empty cmd".into()));
218 }
219 let program = parts.remove(0);
220 let output = tokio::process::Command::new(&program)
221 .args(&parts)
222 .output()
223 .await
224 .map_err(|e| SecretError::Cmd {
225 cmd: format!("{spec} ({e})"),
226 status: -1,
227 })?;
228 if !output.status.success() {
229 return Err(SecretError::Cmd {
230 cmd: spec.to_string(),
231 status: output.status.code().unwrap_or(-1),
232 });
233 }
234 let s = String::from_utf8(output.stdout).map_err(|e| SecretError::Cmd {
235 cmd: format!("{spec} (non-utf8 stdout: {e})"),
236 status: -2,
237 })?;
238 Ok(SecretString::from(
239 s.trim_end_matches(['\n', '\r']).to_string(),
240 ))
241}
242
243async fn resolve_file(path: &std::path::Path) -> Result<SecretString, SecretError> {
244 let expanded = shellexpand::full(&path.to_string_lossy())
245 .map_err(|e| SecretError::Parse(format!("expand {path:?}: {e}")))?
246 .to_string();
247 let p = std::path::PathBuf::from(expanded);
248
249 #[cfg(unix)]
250 {
251 use std::os::unix::fs::PermissionsExt;
252 let meta = tokio::fs::metadata(&p)
253 .await
254 .map_err(|e| SecretError::FileRead {
255 path: p.display().to_string(),
256 source: e,
257 })?;
258 let mode = meta.permissions().mode() & 0o777;
259 if mode & 0o077 != 0 {
260 return Err(SecretError::FileMode(format!(
261 "{}: mode {:o} grants group/world access",
262 p.display(),
263 mode
264 )));
265 }
266 }
267
268 let bytes = tokio::fs::read(&p)
269 .await
270 .map_err(|e| SecretError::FileRead {
271 path: p.display().to_string(),
272 source: e,
273 })?;
274
275 let plaintext = if p.extension().and_then(|s| s.to_str()) == Some("age") {
276 decrypt_age(&bytes).await?
277 } else {
278 String::from_utf8(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?
279 };
280 let trimmed = plaintext.trim_end_matches(['\n', '\r']).to_string();
281 Ok(SecretString::from(trimmed))
282}
283
284async fn decrypt_age(bytes: &[u8]) -> Result<String, SecretError> {
285 let id_path: std::path::PathBuf = match std::env::var("MUR_AGE_IDENTITY_PATH") {
286 Ok(p) => std::path::PathBuf::from(p),
287 Err(_) => dirs::home_dir()
288 .ok_or_else(|| {
289 SecretError::AgeDecrypt(
290 "MUR_AGE_IDENTITY_PATH unset and home dir not resolvable".into(),
291 )
292 })?
293 .join(".mur/age/identity.txt"),
294 };
295
296 let id_str = tokio::fs::read_to_string(&id_path).await.map_err(|e| {
297 SecretError::AgeDecrypt(format!("read identity {}: {}", id_path.display(), e))
298 })?;
299 let identity: age::x25519::Identity = id_str
300 .trim()
301 .parse()
302 .map_err(|e: &str| SecretError::AgeDecrypt(format!("parse identity: {e}")))?;
303
304 let decryptor =
305 age::Decryptor::new(bytes).map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
306 let mut reader = decryptor
307 .decrypt(std::iter::once(&identity as &dyn age::Identity))
308 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
309 let mut out = String::new();
310 use std::io::Read;
311 reader
312 .read_to_string(&mut out)
313 .map_err(|e| SecretError::AgeDecrypt(e.to_string()))?;
314 Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use serde_yaml_ng as yaml;
321
322 #[test]
323 fn parses_env_form() {
324 let s: SecretRef = yaml::from_str("env:ANTHROPIC_API_KEY").unwrap();
325 assert_eq!(s, SecretRef::Env("ANTHROPIC_API_KEY".into()));
326 }
327
328 #[test]
329 fn parses_keychain_form() {
330 let s: SecretRef = yaml::from_str("keychain:mur/anthropic-oauth").unwrap();
331 assert_eq!(
332 s,
333 SecretRef::Keychain {
334 service: "mur".into(),
335 account: "anthropic-oauth".into()
336 }
337 );
338 }
339
340 #[test]
341 fn parses_file_form() {
342 let s: SecretRef = yaml::from_str("file:/tmp/foo.age").unwrap();
343 assert_eq!(s, SecretRef::File(PathBuf::from("/tmp/foo.age")));
344 }
345
346 #[test]
347 fn parses_cmd_form() {
348 let s: SecretRef = yaml::from_str("cmd:op read op://vault/item/field").unwrap();
349 assert_eq!(s, SecretRef::Cmd("op read op://vault/item/field".into()));
350 }
351
352 #[test]
353 fn rejects_unknown_scheme() {
354 let r: Result<SecretRef, _> = yaml::from_str("plain:supersecret");
355 assert!(r.is_err());
356 }
357
358 #[test]
359 fn round_trip_serde() {
360 let cases = [
361 "env:X",
362 "keychain:svc/acct",
363 "file:/p",
364 "cmd:bin --flag arg",
365 ];
366 for s in cases {
367 let parsed: SecretRef = yaml::from_str(s).unwrap();
368 let back = yaml::to_string(&parsed).unwrap();
369 let normalized = back
371 .trim()
372 .trim_matches(|c: char| c == '"' || c == '\'')
373 .to_string();
374 let reparsed: SecretRef = yaml::from_str(&normalized).unwrap();
375 assert_eq!(parsed, reparsed, "round-trip drift for {s}");
376 }
377 }
378}
379
380#[cfg(test)]
381mod resolve_env_tests {
382 use super::*;
383 use secrecy::ExposeSecret;
384
385 #[tokio::test]
386 async fn resolves_env_when_set() {
387 unsafe {
389 std::env::set_var("MUR_TEST_RESOLVE_ENV", "shhh");
390 }
391 let s = SecretRef::Env("MUR_TEST_RESOLVE_ENV".into());
392 let v = s.resolve().await.unwrap();
393 assert_eq!(v.expose_secret(), "shhh");
394 }
395
396 #[tokio::test]
397 async fn errors_when_env_missing() {
398 let s = SecretRef::Env("MUR_TEST_DEFINITELY_UNSET".into());
399 let err = s.resolve().await.unwrap_err();
400 assert!(matches!(err, SecretError::EnvNotSet(_)), "got {err:?}");
401 }
402
403 #[tokio::test]
404 async fn resolve_to_string_exposes_value_or_none() {
405 unsafe {
407 std::env::set_var("MUR_TEST_RESOLVE_TO_STRING", "kc-abc");
408 }
409 let set = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING".into());
410 assert_eq!(set.resolve_to_string().await.as_deref(), Some("kc-abc"));
411
412 let missing = SecretRef::Env("MUR_TEST_RESOLVE_TO_STRING_UNSET".into());
413 assert_eq!(missing.resolve_to_string().await, None);
414 }
415}
416
417#[cfg(test)]
418mod keychain_test_fixture {
419 use keyring::credential::{
431 Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
432 };
433 use std::any::Any;
434 use std::collections::HashMap;
435 use std::sync::{Arc, Mutex};
436 use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard};
437
438 type Store = Arc<Mutex<HashMap<(String, String), Vec<u8>>>>;
439
440 struct SharedMockCredential {
441 store: Store,
442 key: (String, String),
443 }
444
445 impl CredentialApi for SharedMockCredential {
446 fn set_secret(&self, password: &[u8]) -> keyring::Result<()> {
447 self.store
448 .lock()
449 .unwrap()
450 .insert(self.key.clone(), password.to_vec());
451 Ok(())
452 }
453 fn get_secret(&self) -> keyring::Result<Vec<u8>> {
454 self.store
455 .lock()
456 .unwrap()
457 .get(&self.key)
458 .cloned()
459 .ok_or(keyring::Error::NoEntry)
460 }
461 fn delete_credential(&self) -> keyring::Result<()> {
462 self.store
463 .lock()
464 .unwrap()
465 .remove(&self.key)
466 .map(|_| ())
467 .ok_or(keyring::Error::NoEntry)
468 }
469 fn as_any(&self) -> &dyn Any {
470 self
471 }
472 }
473
474 struct SharedMockBuilder {
475 store: Store,
476 }
477
478 impl CredentialBuilderApi for SharedMockBuilder {
479 fn build(
480 &self,
481 _target: Option<&str>,
482 service: &str,
483 user: &str,
484 ) -> keyring::Result<Box<Credential>> {
485 Ok(Box::new(SharedMockCredential {
486 store: self.store.clone(),
487 key: (service.to_string(), user.to_string()),
488 }))
489 }
490 fn as_any(&self) -> &dyn Any {
491 self
492 }
493 fn persistence(&self) -> CredentialPersistence {
494 CredentialPersistence::ProcessOnly
495 }
496 }
497
498 static MOCK_LOCK: AsyncMutex<()> = AsyncMutex::const_new(());
499
500 pub(super) async fn install_mock(
501 initial: Option<(&str, &str, &str)>,
502 ) -> AsyncMutexGuard<'static, ()> {
503 let g = MOCK_LOCK.lock().await;
504 let store: Store = Arc::new(Mutex::new(HashMap::new()));
505 if let Some((svc, user, pw)) = initial {
506 store
507 .lock()
508 .unwrap()
509 .insert((svc.to_string(), user.to_string()), pw.as_bytes().to_vec());
510 }
511 let builder: Box<CredentialBuilder> = Box::new(SharedMockBuilder { store });
512 keyring::set_default_credential_builder(builder);
513 g
514 }
515}
516
517#[cfg(test)]
518mod resolve_keychain_tests {
519 use super::keychain_test_fixture::install_mock;
520 use super::*;
521 use secrecy::ExposeSecret;
522
523 #[tokio::test]
524 async fn resolves_when_set() {
525 let _g = install_mock(Some(("mur-test", "kc-acct", "kc-secret"))).await;
526 let s = SecretRef::Keychain {
527 service: "mur-test".into(),
528 account: "kc-acct".into(),
529 };
530 let v = s.resolve().await.unwrap();
531 assert_eq!(v.expose_secret(), "kc-secret");
532 }
533
534 #[tokio::test]
535 async fn errors_when_missing() {
536 let _g = install_mock(None).await;
537 let s = SecretRef::Keychain {
538 service: "mur-test".into(),
539 account: "kc-acct".into(),
540 };
541 let err = s.resolve().await.unwrap_err();
542 assert!(
543 matches!(err, SecretError::KeychainNotFound { .. }),
544 "got {err:?}"
545 );
546 }
547}
548
549#[cfg(all(test, unix))]
550mod resolve_file_tests {
551 use super::*;
552 use secrecy::ExposeSecret;
553 use std::os::unix::fs::PermissionsExt;
554 use tempfile::tempdir;
555
556 #[tokio::test]
557 async fn reads_plaintext_0600() {
558 let dir = tempdir().unwrap();
559 let p = dir.path().join("k.txt");
560 std::fs::write(&p, "abc\n").unwrap();
561 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
562 let s = SecretRef::File(p);
563 let v = s.resolve().await.unwrap();
564 assert_eq!(v.expose_secret(), "abc"); }
566
567 #[tokio::test]
568 async fn rejects_world_readable() {
569 let dir = tempdir().unwrap();
570 let p = dir.path().join("k.txt");
571 std::fs::write(&p, "abc").unwrap();
572 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
573 let s = SecretRef::File(p);
574 let err = s.resolve().await.unwrap_err();
575 assert!(matches!(err, SecretError::FileMode(_)), "got {err:?}");
576 }
577
578 #[tokio::test]
579 async fn decrypts_age_recipient_file() {
580 let dir = tempdir().unwrap();
581 let identity = age::x25519::Identity::generate();
582 let recipient = identity.to_public();
583 let payload = b"shh-from-age";
584
585 let mut encrypted: Vec<u8> = Vec::new();
586 let encryptor =
587 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
588 .unwrap();
589 let mut writer = encryptor.wrap_output(&mut encrypted).unwrap();
590 std::io::Write::write_all(&mut writer, payload).unwrap();
591 writer.finish().unwrap();
592
593 let enc_path = dir.path().join("k.age");
594 std::fs::write(&enc_path, &encrypted).unwrap();
595 std::fs::set_permissions(&enc_path, std::fs::Permissions::from_mode(0o600)).unwrap();
596 let id_path = dir.path().join("identity.txt");
597 use secrecy::ExposeSecret as _;
598 std::fs::write(&id_path, identity.to_string().expose_secret()).unwrap();
599 std::fs::set_permissions(&id_path, std::fs::Permissions::from_mode(0o600)).unwrap();
600 unsafe {
604 std::env::set_var("MUR_AGE_IDENTITY_PATH", &id_path);
605 }
606 let s = SecretRef::File(enc_path);
607 let v = s.resolve().await.unwrap();
608 assert_eq!(v.expose_secret(), "shh-from-age");
609 unsafe {
610 std::env::remove_var("MUR_AGE_IDENTITY_PATH");
611 }
612 }
613}
614
615#[cfg(all(test, unix))]
616mod resolve_cmd_tests {
617 use super::*;
618 use secrecy::ExposeSecret;
619
620 #[tokio::test]
621 async fn echoes_stdout() {
622 let s = SecretRef::Cmd("printf shh-from-cmd".into());
623 let v = s.resolve().await.unwrap();
624 assert_eq!(v.expose_secret(), "shh-from-cmd");
625 }
626
627 #[tokio::test]
628 async fn errors_on_non_zero_exit() {
629 let s = SecretRef::Cmd("sh -c 'exit 7'".into());
630 let err = s.resolve().await.unwrap_err();
631 match err {
632 SecretError::Cmd { status, .. } => assert_eq!(status, 7),
633 other => panic!("unexpected: {other:?}"),
634 }
635 }
636}
637
638#[cfg(test)]
639mod check_tests {
640 use super::*;
641
642 #[tokio::test]
643 async fn check_env_present() {
644 unsafe {
646 std::env::set_var("MUR_TEST_CHECK_ENV", "1");
647 }
648 assert!(SecretRef::Env("MUR_TEST_CHECK_ENV".into()).check().await);
649 }
650
651 #[tokio::test]
652 async fn check_env_absent() {
653 assert!(
654 !SecretRef::Env("MUR_TEST_CHECK_DEFINITELY_UNSET".into())
655 .check()
656 .await
657 );
658 }
659}
660
661#[cfg(test)]
662mod keychain_helpers_tests {
663 use super::keychain_test_fixture::install_mock;
664 use super::*;
665 use secrecy::ExposeSecret;
666
667 #[tokio::test]
668 async fn set_then_resolve_round_trips() {
669 let _g = install_mock(None).await;
670 keychain_set("mur-test", "round-trip", "v1").await.unwrap();
671 let v = SecretRef::Keychain {
672 service: "mur-test".into(),
673 account: "round-trip".into(),
674 }
675 .resolve()
676 .await
677 .unwrap();
678 assert_eq!(v.expose_secret(), "v1");
679 }
680
681 #[tokio::test]
682 async fn delete_works() {
683 let _g = install_mock(None).await;
684 keychain_set("mur-test", "to-delete", "v").await.unwrap();
685 keychain_delete("mur-test", "to-delete").await.unwrap();
686 let r = SecretRef::Keychain {
687 service: "mur-test".into(),
688 account: "to-delete".into(),
689 }
690 .resolve()
691 .await;
692 assert!(matches!(r, Err(SecretError::KeychainNotFound { .. })));
693 }
694
695 #[tokio::test]
696 async fn delete_missing_is_idempotent() {
697 let _g = install_mock(None).await;
698 keychain_delete("mur-test", "never-set").await.unwrap();
700 }
701}