1use std::collections::BTreeMap;
61use std::fmt;
62use std::future::Future;
63use std::io::{Read as _, Write as _};
64use std::path::{Path, PathBuf};
65use std::pin::Pin;
66use std::sync::Arc;
67
68use zeroize::Zeroizing;
69
70pub use zeph_common::secret::{Secret, VaultError};
73
74pub trait VaultProvider: Send + Sync {
100 fn get_secret(
105 &self,
106 key: &str,
107 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>>;
108
109 fn list_keys(&self) -> Vec<String> {
115 Vec::new()
116 }
117}
118
119pub struct EnvVaultProvider;
141
142#[derive(Debug, thiserror::Error)]
156pub enum AgeVaultError {
157 #[error("failed to read key file: {0}")]
159 KeyRead(std::io::Error),
160 #[error("failed to parse age identity: {0}")]
162 KeyParse(String),
163 #[error("failed to read vault file: {0}")]
165 VaultRead(std::io::Error),
166 #[error("age decryption failed: {0}")]
168 Decrypt(age::DecryptError),
169 #[error("I/O error during decryption: {0}")]
171 Io(std::io::Error),
172 #[error("invalid JSON in vault: {0}")]
174 Json(serde_json::Error),
175 #[error("age encryption failed: {0}")]
177 Encrypt(String),
178 #[error("failed to write vault file: {0}")]
180 VaultWrite(std::io::Error),
181 #[error("failed to write key file: {0}")]
183 KeyWrite(std::io::Error),
184}
185
186pub struct AgeVaultProvider {
219 secrets: BTreeMap<String, Zeroizing<String>>,
220 key_path: PathBuf,
221 vault_path: PathBuf,
222}
223
224impl fmt::Debug for AgeVaultProvider {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 f.debug_struct("AgeVaultProvider")
227 .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
228 .field("key_path", &self.key_path)
229 .field("vault_path", &self.vault_path)
230 .finish()
231 }
232}
233
234impl AgeVaultProvider {
235 pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
263 Self::load(key_path, vault_path)
264 }
265
266 pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
289 let key_str =
290 Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
291 let identity = parse_identity(&key_str)?;
292 let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
293 let secrets = decrypt_secrets(&identity, &ciphertext)?;
294 Ok(Self {
295 secrets,
296 key_path: key_path.to_owned(),
297 vault_path: vault_path.to_owned(),
298 })
299 }
300
301 pub fn save(&self) -> Result<(), AgeVaultError> {
325 let key_str = Zeroizing::new(
326 std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
327 );
328 let identity = parse_identity(&key_str)?;
329 let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
330 atomic_write(&self.vault_path, &ciphertext)
331 }
332
333 pub fn set_secret_mut(&mut self, key: String, value: String) {
352 self.secrets.insert(key, Zeroizing::new(value));
353 }
354
355 pub fn remove_secret_mut(&mut self, key: &str) -> bool {
377 self.secrets.remove(key).is_some()
378 }
379
380 #[must_use]
400 pub fn list_keys(&self) -> Vec<&str> {
401 let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
402 keys.sort_unstable();
403 keys
404 }
405
406 #[must_use]
428 pub fn get(&self, key: &str) -> Option<&str> {
429 self.secrets.get(key).map(|v| v.as_str())
430 }
431
432 pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
461 use age::secrecy::ExposeSecret as _;
462
463 std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
464
465 let identity = age::x25519::Identity::generate();
466 let public_key = identity.to_public();
467
468 let key_content = Zeroizing::new(format!(
469 "# public key: {}\n{}\n",
470 public_key,
471 identity.to_string().expose_secret()
472 ));
473
474 let key_path = dir.join("vault-key.txt");
475 write_private_file(&key_path, key_content.as_bytes())?;
476
477 let vault_path = dir.join("secrets.age");
478 let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
479 let ciphertext = encrypt_secrets(&identity, &empty)?;
480 atomic_write(&vault_path, &ciphertext)?;
481
482 println!("Vault initialized:");
483 println!(" Key: {}", key_path.display());
484 println!(" Vault: {}", vault_path.display());
485
486 Ok(())
487 }
488}
489
490#[must_use]
505pub fn default_vault_dir() -> PathBuf {
506 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
507 return PathBuf::from(xdg).join("zeph");
508 }
509 if let Ok(appdata) = std::env::var("APPDATA") {
510 return PathBuf::from(appdata).join("zeph");
511 }
512 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
513 PathBuf::from(home).join(".config").join("zeph")
514}
515
516fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
517 let key_line = key_str
518 .lines()
519 .find(|l| !l.starts_with('#') && !l.trim().is_empty())
520 .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
521 key_line
522 .trim()
523 .parse()
524 .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
525}
526
527fn decrypt_secrets(
528 identity: &age::x25519::Identity,
529 ciphertext: &[u8],
530) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
531 let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
532 let mut reader = decryptor
533 .decrypt(std::iter::once(identity as &dyn age::Identity))
534 .map_err(AgeVaultError::Decrypt)?;
535 let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
536 reader
537 .read_to_end(&mut plaintext)
538 .map_err(AgeVaultError::Io)?;
539 let raw: BTreeMap<String, String> =
540 serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
541 Ok(raw
542 .into_iter()
543 .map(|(k, v)| (k, Zeroizing::new(v)))
544 .collect())
545}
546
547fn encrypt_secrets(
548 identity: &age::x25519::Identity,
549 secrets: &BTreeMap<String, Zeroizing<String>>,
550) -> Result<Vec<u8>, AgeVaultError> {
551 let recipient = identity.to_public();
552 let encryptor =
553 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
554 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
555 let plain: BTreeMap<&str, &str> = secrets
556 .iter()
557 .map(|(k, v)| (k.as_str(), v.as_str()))
558 .collect();
559 let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
560 let mut ciphertext = Vec::with_capacity(json.len() + 64);
561 let mut writer = encryptor
562 .wrap_output(&mut ciphertext)
563 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
564 writer.write_all(&json).map_err(AgeVaultError::Io)?;
565 writer
566 .finish()
567 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
568 Ok(ciphertext)
569}
570
571fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
572 let tmp_path = path.with_extension("age.tmp");
573 std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?;
574 std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite)
575}
576
577#[cfg(unix)]
578fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
579 use std::os::unix::fs::OpenOptionsExt as _;
580 let mut file = std::fs::OpenOptions::new()
581 .write(true)
582 .create(true)
583 .truncate(true)
584 .mode(0o600)
585 .open(path)
586 .map_err(AgeVaultError::KeyWrite)?;
587 file.write_all(data).map_err(AgeVaultError::KeyWrite)
588}
589
590#[cfg(not(unix))]
593fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
594 std::fs::write(path, data).map_err(AgeVaultError::KeyWrite)
595}
596
597impl VaultProvider for AgeVaultProvider {
598 fn get_secret(
599 &self,
600 key: &str,
601 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
602 let result = self.secrets.get(key).map(|v| (**v).clone());
603 Box::pin(async move { Ok(result) })
604 }
605
606 fn list_keys(&self) -> Vec<String> {
607 let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
608 keys.sort_unstable();
609 keys
610 }
611}
612
613impl VaultProvider for EnvVaultProvider {
614 fn get_secret(
615 &self,
616 key: &str,
617 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
618 let key = key.to_owned();
619 Box::pin(async move { Ok(std::env::var(&key).ok()) })
620 }
621
622 fn list_keys(&self) -> Vec<String> {
623 let mut keys: Vec<String> = std::env::vars()
624 .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
625 .map(|(k, _)| k)
626 .collect();
627 keys.sort_unstable();
628 keys
629 }
630}
631
632pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
660
661impl VaultProvider for ArcAgeVaultProvider {
662 fn get_secret(
663 &self,
664 key: &str,
665 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
666 let arc = Arc::clone(&self.0);
667 let key = key.to_owned();
668 Box::pin(async move {
669 let guard = arc.read().await;
670 Ok(guard.get(&key).map(str::to_owned))
671 })
672 }
673
674 fn list_keys(&self) -> Vec<String> {
675 let arc = Arc::clone(&self.0);
678 let guard = tokio::task::block_in_place(|| arc.blocking_read());
679 let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
680 keys.sort_unstable();
681 keys
682 }
683}
684
685#[cfg(any(test, feature = "mock"))]
715#[derive(Default)]
716pub struct MockVaultProvider {
717 secrets: std::collections::BTreeMap<String, String>,
718 listed_only: Vec<String>,
721}
722
723#[cfg(any(test, feature = "mock"))]
724impl MockVaultProvider {
725 #[must_use]
736 pub fn new() -> Self {
737 Self::default()
738 }
739
740 #[must_use]
756 pub fn with_secret(mut self, key: &str, value: &str) -> Self {
757 self.secrets.insert(key.to_owned(), value.to_owned());
758 self
759 }
760
761 #[must_use]
776 pub fn with_listed_key(mut self, key: &str) -> Self {
777 self.listed_only.push(key.to_owned());
778 self
779 }
780}
781
782#[cfg(any(test, feature = "mock"))]
783impl VaultProvider for MockVaultProvider {
784 fn get_secret(
785 &self,
786 key: &str,
787 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
788 let result = self.secrets.get(key).cloned();
789 Box::pin(async move { Ok(result) })
790 }
791
792 fn list_keys(&self) -> Vec<String> {
793 let mut keys: Vec<String> = self
794 .secrets
795 .keys()
796 .cloned()
797 .chain(self.listed_only.iter().cloned())
798 .collect();
799 keys.sort_unstable();
800 keys.dedup();
801 keys
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 #![allow(clippy::doc_markdown)]
808
809 use super::*;
810
811 #[test]
812 fn secret_expose_returns_inner() {
813 let secret = Secret::new("my-api-key");
814 assert_eq!(secret.expose(), "my-api-key");
815 }
816
817 #[test]
818 fn secret_debug_is_redacted() {
819 let secret = Secret::new("my-api-key");
820 assert_eq!(format!("{secret:?}"), "[REDACTED]");
821 }
822
823 #[test]
824 fn secret_display_is_redacted() {
825 let secret = Secret::new("my-api-key");
826 assert_eq!(format!("{secret}"), "[REDACTED]");
827 }
828
829 #[allow(unsafe_code)]
830 #[tokio::test]
831 async fn env_vault_returns_set_var() {
832 let key = "ZEPH_TEST_VAULT_SECRET_SET";
833 unsafe { std::env::set_var(key, "test-value") };
834 let vault = EnvVaultProvider;
835 let result = vault.get_secret(key).await.unwrap();
836 unsafe { std::env::remove_var(key) };
837 assert_eq!(result.as_deref(), Some("test-value"));
838 }
839
840 #[tokio::test]
841 async fn env_vault_returns_none_for_unset() {
842 let vault = EnvVaultProvider;
843 let result = vault
844 .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
845 .await
846 .unwrap();
847 assert!(result.is_none());
848 }
849
850 #[tokio::test]
851 async fn mock_vault_returns_configured_secret() {
852 let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
853 let result = vault.get_secret("API_KEY").await.unwrap();
854 assert_eq!(result.as_deref(), Some("secret-123"));
855 }
856
857 #[tokio::test]
858 async fn mock_vault_returns_none_for_missing() {
859 let vault = MockVaultProvider::new();
860 let result = vault.get_secret("MISSING").await.unwrap();
861 assert!(result.is_none());
862 }
863
864 #[test]
865 fn secret_from_string() {
866 let s = Secret::new(String::from("test"));
867 assert_eq!(s.expose(), "test");
868 }
869
870 #[test]
871 fn secret_expose_roundtrip() {
872 let s = Secret::new("test");
873 let owned = s.expose().to_owned();
874 let s2 = Secret::new(owned);
875 assert_eq!(s.expose(), s2.expose());
876 }
877
878 #[test]
879 fn secret_deserialize() {
880 let json = "\"my-secret-value\"";
881 let secret: Secret = serde_json::from_str(json).unwrap();
882 assert_eq!(secret.expose(), "my-secret-value");
883 assert_eq!(format!("{secret:?}"), "[REDACTED]");
884 }
885
886 #[test]
887 fn mock_vault_list_keys_sorted() {
888 let vault = MockVaultProvider::new()
889 .with_secret("B_KEY", "v2")
890 .with_secret("A_KEY", "v1")
891 .with_secret("C_KEY", "v3");
892 let mut keys = vault.list_keys();
893 keys.sort_unstable();
894 assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
895 }
896
897 #[test]
898 fn mock_vault_list_keys_empty() {
899 let vault = MockVaultProvider::new();
900 assert!(vault.list_keys().is_empty());
901 }
902
903 #[allow(unsafe_code)]
904 #[test]
905 fn env_vault_list_keys_filters_zeph_secret_prefix() {
906 let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
907 unsafe { std::env::set_var(key, "v") };
908 let vault = EnvVaultProvider;
909 let keys = vault.list_keys();
910 assert!(keys.contains(&key.to_owned()));
911 unsafe { std::env::remove_var(key) };
912 }
913}
914
915#[cfg(test)]
916mod age_tests {
917 use std::io::Write as _;
918
919 use age::secrecy::ExposeSecret;
920
921 use super::*;
922
923 fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
924 let recipient = identity.to_public();
925 let encryptor =
926 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
927 .expect("encryptor creation");
928 let mut encrypted = vec![];
929 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
930 writer
931 .write_all(json.to_string().as_bytes())
932 .expect("write plaintext");
933 writer.finish().expect("finish encryption");
934 encrypted
935 }
936
937 fn write_temp_files(
938 identity: &age::x25519::Identity,
939 ciphertext: &[u8],
940 ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
941 let dir = tempfile::tempdir().expect("tempdir");
942 let key_path = dir.path().join("key.txt");
943 let vault_path = dir.path().join("secrets.age");
944 std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
945 std::fs::write(&vault_path, ciphertext).expect("write vault");
946 (dir, key_path, vault_path)
947 }
948
949 #[tokio::test]
950 async fn age_vault_returns_existing_secret() {
951 let identity = age::x25519::Identity::generate();
952 let json = serde_json::json!({"KEY": "value"});
953 let encrypted = encrypt_json(&identity, &json);
954 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
955
956 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
957 let result = vault.get_secret("KEY").await.unwrap();
958 assert_eq!(result.as_deref(), Some("value"));
959 }
960
961 #[tokio::test]
962 async fn age_vault_returns_none_for_missing() {
963 let identity = age::x25519::Identity::generate();
964 let json = serde_json::json!({"KEY": "value"});
965 let encrypted = encrypt_json(&identity, &json);
966 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
967
968 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
969 let result = vault.get_secret("MISSING").await.unwrap();
970 assert!(result.is_none());
971 }
972
973 #[test]
974 fn age_vault_bad_key_file() {
975 let err = AgeVaultProvider::new(
976 Path::new("/nonexistent/key.txt"),
977 Path::new("/nonexistent/vault.age"),
978 )
979 .unwrap_err();
980 assert!(matches!(err, AgeVaultError::KeyRead(_)));
981 }
982
983 #[test]
984 fn age_vault_bad_key_parse() {
985 let dir = tempfile::tempdir().unwrap();
986 let key_path = dir.path().join("bad-key.txt");
987 std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
988
989 let vault_path = dir.path().join("vault.age");
990 std::fs::write(&vault_path, b"dummy").unwrap();
991
992 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
993 assert!(matches!(err, AgeVaultError::KeyParse(_)));
994 }
995
996 #[test]
997 fn age_vault_bad_vault_file() {
998 let dir = tempfile::tempdir().unwrap();
999 let identity = age::x25519::Identity::generate();
1000 let key_path = dir.path().join("key.txt");
1001 std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
1002
1003 let err =
1004 AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
1005 assert!(matches!(err, AgeVaultError::VaultRead(_)));
1006 }
1007
1008 #[test]
1009 fn age_vault_wrong_key() {
1010 let identity = age::x25519::Identity::generate();
1011 let wrong_identity = age::x25519::Identity::generate();
1012 let json = serde_json::json!({"KEY": "value"});
1013 let encrypted = encrypt_json(&identity, &json);
1014 let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
1015
1016 let dir2 = tempfile::tempdir().unwrap();
1017 let wrong_key_path = dir2.path().join("wrong-key.txt");
1018 std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
1019
1020 let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
1021 assert!(matches!(err, AgeVaultError::Decrypt(_)));
1022 }
1023
1024 #[test]
1025 fn age_vault_invalid_json() {
1026 let identity = age::x25519::Identity::generate();
1027 let recipient = identity.to_public();
1028 let encryptor =
1029 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1030 .expect("encryptor");
1031 let mut encrypted = vec![];
1032 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1033 writer.write_all(b"not json").expect("write");
1034 writer.finish().expect("finish");
1035
1036 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1037 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1038 assert!(matches!(err, AgeVaultError::Json(_)));
1039 }
1040
1041 #[test]
1042 fn age_vault_debug_impl() {
1043 let identity = age::x25519::Identity::generate();
1044 let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
1045 let encrypted = encrypt_json(&identity, &json);
1046 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1047
1048 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1049 let debug = format!("{vault:?}");
1050 assert!(debug.contains("AgeVaultProvider"));
1051 assert!(debug.contains("[2 secrets]"));
1052 assert!(!debug.contains("value1"));
1053 }
1054
1055 #[tokio::test]
1056 async fn age_vault_key_file_with_comments() {
1057 let identity = age::x25519::Identity::generate();
1058 let json = serde_json::json!({"KEY": "value"});
1059 let encrypted = encrypt_json(&identity, &json);
1060 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1061
1062 let key_with_comments = format!(
1063 "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
1064 identity.to_public(),
1065 identity.to_string().expose_secret()
1066 );
1067 std::fs::write(&key_path, &key_with_comments).unwrap();
1068
1069 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1070 let result = vault.get_secret("KEY").await.unwrap();
1071 assert_eq!(result.as_deref(), Some("value"));
1072 }
1073
1074 #[test]
1075 fn age_vault_key_file_only_comments() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let key_path = dir.path().join("comments-only.txt");
1078 std::fs::write(&key_path, "# comment\n# another\n").unwrap();
1079 let vault_path = dir.path().join("vault.age");
1080 std::fs::write(&vault_path, b"dummy").unwrap();
1081
1082 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1083 assert!(matches!(err, AgeVaultError::KeyParse(_)));
1084 }
1085
1086 #[test]
1087 fn age_vault_error_display() {
1088 let key_err =
1089 AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1090 assert!(key_err.to_string().contains("failed to read key file"));
1091
1092 let parse_err = AgeVaultError::KeyParse("bad key".into());
1093 assert!(
1094 parse_err
1095 .to_string()
1096 .contains("failed to parse age identity")
1097 );
1098
1099 let vault_err =
1100 AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1101 assert!(vault_err.to_string().contains("failed to read vault file"));
1102
1103 let enc_err = AgeVaultError::Encrypt("bad".into());
1104 assert!(enc_err.to_string().contains("age encryption failed"));
1105
1106 let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
1107 std::io::ErrorKind::PermissionDenied,
1108 "test",
1109 ));
1110 assert!(write_err.to_string().contains("failed to write vault file"));
1111 }
1112
1113 #[test]
1114 fn age_vault_set_and_list_keys() {
1115 let identity = age::x25519::Identity::generate();
1116 let json = serde_json::json!({"A": "1"});
1117 let encrypted = encrypt_json(&identity, &json);
1118 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1119
1120 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1121 vault.set_secret_mut("B".to_owned(), "2".to_owned());
1122 vault.set_secret_mut("C".to_owned(), "3".to_owned());
1123
1124 let keys = vault.list_keys();
1125 assert_eq!(keys, vec!["A", "B", "C"]);
1126 }
1127
1128 #[test]
1129 fn age_vault_remove_secret() {
1130 let identity = age::x25519::Identity::generate();
1131 let json = serde_json::json!({"X": "val", "Y": "val2"});
1132 let encrypted = encrypt_json(&identity, &json);
1133 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1134
1135 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1136 assert!(vault.remove_secret_mut("X"));
1137 assert!(!vault.remove_secret_mut("NONEXISTENT"));
1138 assert_eq!(vault.list_keys(), vec!["Y"]);
1139 }
1140
1141 #[tokio::test]
1142 async fn age_vault_save_roundtrip() {
1143 let identity = age::x25519::Identity::generate();
1144 let json = serde_json::json!({"ORIG": "value"});
1145 let encrypted = encrypt_json(&identity, &json);
1146 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1147
1148 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1149 vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
1150 vault.save().unwrap();
1151
1152 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1153 let result = reloaded.get_secret("NEW_KEY").await.unwrap();
1154 assert_eq!(result.as_deref(), Some("new_value"));
1155
1156 let orig = reloaded.get_secret("ORIG").await.unwrap();
1157 assert_eq!(orig.as_deref(), Some("value"));
1158 }
1159
1160 #[test]
1161 fn age_vault_get_method_returns_str() {
1162 let identity = age::x25519::Identity::generate();
1163 let json = serde_json::json!({"FOO": "bar"});
1164 let encrypted = encrypt_json(&identity, &json);
1165 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1166
1167 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1168 assert_eq!(vault.get("FOO"), Some("bar"));
1169 assert_eq!(vault.get("MISSING"), None);
1170 }
1171
1172 #[test]
1173 fn age_vault_empty_secret_value() {
1174 let identity = age::x25519::Identity::generate();
1175 let json = serde_json::json!({"EMPTY": ""});
1176 let encrypted = encrypt_json(&identity, &json);
1177 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1178
1179 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1180 assert_eq!(vault.get("EMPTY"), Some(""));
1181 }
1182
1183 #[test]
1184 fn age_vault_init_vault() {
1185 let dir = tempfile::tempdir().unwrap();
1186 AgeVaultProvider::init_vault(dir.path()).unwrap();
1187
1188 let key_path = dir.path().join("vault-key.txt");
1189 let vault_path = dir.path().join("secrets.age");
1190 assert!(key_path.exists());
1191 assert!(vault_path.exists());
1192
1193 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1194 assert_eq!(vault.list_keys(), Vec::<&str>::new());
1195 }
1196
1197 #[tokio::test]
1198 async fn age_vault_keys_sorted_after_roundtrip() {
1199 let identity = age::x25519::Identity::generate();
1200 let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
1202 let encrypted = encrypt_json(&identity, &json);
1203 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1204
1205 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1206 let keys = vault.list_keys();
1207 assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
1208 }
1209
1210 #[test]
1211 fn age_vault_save_preserves_key_order() {
1212 let identity = age::x25519::Identity::generate();
1213 let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
1214 let encrypted = encrypt_json(&identity, &json);
1215 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1216
1217 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1218 vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
1219 vault.save().unwrap();
1220
1221 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1222 let keys = reloaded.list_keys();
1223 assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
1224 }
1225
1226 #[test]
1227 fn age_vault_decrypt_returns_btreemap_sorted() {
1228 let identity = age::x25519::Identity::generate();
1229 let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
1231 let recipient = identity.to_public();
1232 let encryptor =
1233 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1234 .expect("encryptor");
1235 let mut encrypted = vec![];
1236 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1237 writer.write_all(json_str.as_bytes()).expect("write");
1238 writer.finish().expect("finish");
1239
1240 let ciphertext = encrypted;
1241 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1242 let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
1243 assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
1245 }
1246
1247 #[test]
1248 fn age_vault_into_iter_consumes_all_entries() {
1249 let identity = age::x25519::Identity::generate();
1252 let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
1253 let encrypted = encrypt_json(&identity, &json);
1254 let ciphertext = encrypted;
1255 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1256
1257 let mut pairs: Vec<(String, String)> = secrets
1258 .into_iter()
1259 .map(|(k, v)| (k, v.as_str().to_owned()))
1260 .collect();
1261 pairs.sort_by(|a, b| a.0.cmp(&b.0));
1262
1263 assert_eq!(pairs.len(), 3);
1264 assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
1265 assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
1266 assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
1267 }
1268
1269 use proptest::prelude::*;
1270
1271 proptest! {
1272 #[test]
1273 fn secret_value_roundtrip(s in ".*") {
1274 let secret = Secret::new(s.clone());
1275 assert_eq!(secret.expose(), s.as_str());
1276 }
1277
1278 #[test]
1279 fn secret_debug_always_redacted(s in ".*") {
1280 let secret = Secret::new(s);
1281 assert_eq!(format!("{secret:?}"), "[REDACTED]");
1282 }
1283
1284 #[test]
1285 fn secret_display_always_redacted(s in ".*") {
1286 let secret = Secret::new(s);
1287 assert_eq!(format!("{secret}"), "[REDACTED]");
1288 }
1289 }
1290}