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 zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
573}
574
575fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
576 zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
577}
578
579impl VaultProvider for AgeVaultProvider {
580 fn get_secret(
581 &self,
582 key: &str,
583 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
584 let result = self.secrets.get(key).map(|v| (**v).clone());
585 Box::pin(async move { Ok(result) })
586 }
587
588 fn list_keys(&self) -> Vec<String> {
589 let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
590 keys.sort_unstable();
591 keys
592 }
593}
594
595impl VaultProvider for EnvVaultProvider {
596 fn get_secret(
597 &self,
598 key: &str,
599 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
600 let key = key.to_owned();
601 Box::pin(async move { Ok(std::env::var(&key).ok()) })
602 }
603
604 fn list_keys(&self) -> Vec<String> {
605 let mut keys: Vec<String> = std::env::vars()
606 .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
607 .map(|(k, _)| k)
608 .collect();
609 keys.sort_unstable();
610 keys
611 }
612}
613
614pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
642
643impl VaultProvider for ArcAgeVaultProvider {
644 fn get_secret(
645 &self,
646 key: &str,
647 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
648 let arc = Arc::clone(&self.0);
649 let key = key.to_owned();
650 Box::pin(async move {
651 let guard = arc.read().await;
652 Ok(guard.get(&key).map(str::to_owned))
653 })
654 }
655
656 fn list_keys(&self) -> Vec<String> {
657 let arc = Arc::clone(&self.0);
660 let guard = tokio::task::block_in_place(|| arc.blocking_read());
661 let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
662 keys.sort_unstable();
663 keys
664 }
665}
666
667#[cfg(any(test, feature = "mock"))]
697#[derive(Default)]
698pub struct MockVaultProvider {
699 secrets: std::collections::BTreeMap<String, String>,
700 listed_only: Vec<String>,
703}
704
705#[cfg(any(test, feature = "mock"))]
706impl MockVaultProvider {
707 #[must_use]
718 pub fn new() -> Self {
719 Self::default()
720 }
721
722 #[must_use]
738 pub fn with_secret(mut self, key: &str, value: &str) -> Self {
739 self.secrets.insert(key.to_owned(), value.to_owned());
740 self
741 }
742
743 #[must_use]
758 pub fn with_listed_key(mut self, key: &str) -> Self {
759 self.listed_only.push(key.to_owned());
760 self
761 }
762}
763
764#[cfg(any(test, feature = "mock"))]
765impl VaultProvider for MockVaultProvider {
766 fn get_secret(
767 &self,
768 key: &str,
769 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
770 let result = self.secrets.get(key).cloned();
771 Box::pin(async move { Ok(result) })
772 }
773
774 fn list_keys(&self) -> Vec<String> {
775 let mut keys: Vec<String> = self
776 .secrets
777 .keys()
778 .cloned()
779 .chain(self.listed_only.iter().cloned())
780 .collect();
781 keys.sort_unstable();
782 keys.dedup();
783 keys
784 }
785}
786
787#[cfg(test)]
788mod tests {
789 #![allow(clippy::doc_markdown)]
790
791 use super::*;
792
793 #[test]
794 fn atomic_write_uses_age_tmp_suffix() {
795 let dir = tempfile::tempdir().unwrap();
796 let path = dir.path().join("vault.age");
797 atomic_write(&path, b"data").unwrap();
798 assert!(path.exists());
799 let tmp = path.with_added_extension("tmp");
800 assert_eq!(tmp.file_name().unwrap(), "vault.age.tmp");
801 }
802
803 #[cfg(unix)]
804 #[test]
805 fn init_vault_sets_0600_on_both_files() {
806 use std::os::unix::fs::PermissionsExt as _;
807 let dir = tempfile::tempdir().unwrap();
808 AgeVaultProvider::init_vault(dir.path()).unwrap();
809 let key_mode = std::fs::metadata(dir.path().join("vault-key.txt"))
810 .unwrap()
811 .permissions()
812 .mode()
813 & 0o777;
814 let vault_mode = std::fs::metadata(dir.path().join("secrets.age"))
815 .unwrap()
816 .permissions()
817 .mode()
818 & 0o777;
819 assert_eq!(key_mode, 0o600, "vault-key.txt must be 0o600");
820 assert_eq!(vault_mode, 0o600, "secrets.age must be 0o600");
821 }
822
823 #[test]
824 fn secret_expose_returns_inner() {
825 let secret = Secret::new("my-api-key");
826 assert_eq!(secret.expose(), "my-api-key");
827 }
828
829 #[test]
830 fn secret_debug_is_redacted() {
831 let secret = Secret::new("my-api-key");
832 assert_eq!(format!("{secret:?}"), "[REDACTED]");
833 }
834
835 #[test]
836 fn secret_display_is_redacted() {
837 let secret = Secret::new("my-api-key");
838 assert_eq!(format!("{secret}"), "[REDACTED]");
839 }
840
841 #[allow(unsafe_code)]
842 #[tokio::test]
843 async fn env_vault_returns_set_var() {
844 let key = "ZEPH_TEST_VAULT_SECRET_SET";
845 unsafe { std::env::set_var(key, "test-value") };
846 let vault = EnvVaultProvider;
847 let result = vault.get_secret(key).await.unwrap();
848 unsafe { std::env::remove_var(key) };
849 assert_eq!(result.as_deref(), Some("test-value"));
850 }
851
852 #[tokio::test]
853 async fn env_vault_returns_none_for_unset() {
854 let vault = EnvVaultProvider;
855 let result = vault
856 .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
857 .await
858 .unwrap();
859 assert!(result.is_none());
860 }
861
862 #[tokio::test]
863 async fn mock_vault_returns_configured_secret() {
864 let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
865 let result = vault.get_secret("API_KEY").await.unwrap();
866 assert_eq!(result.as_deref(), Some("secret-123"));
867 }
868
869 #[tokio::test]
870 async fn mock_vault_returns_none_for_missing() {
871 let vault = MockVaultProvider::new();
872 let result = vault.get_secret("MISSING").await.unwrap();
873 assert!(result.is_none());
874 }
875
876 #[test]
877 fn secret_from_string() {
878 let s = Secret::new(String::from("test"));
879 assert_eq!(s.expose(), "test");
880 }
881
882 #[test]
883 fn secret_expose_roundtrip() {
884 let s = Secret::new("test");
885 let owned = s.expose().to_owned();
886 let s2 = Secret::new(owned);
887 assert_eq!(s.expose(), s2.expose());
888 }
889
890 #[test]
891 fn secret_deserialize() {
892 let json = "\"my-secret-value\"";
893 let secret: Secret = serde_json::from_str(json).unwrap();
894 assert_eq!(secret.expose(), "my-secret-value");
895 assert_eq!(format!("{secret:?}"), "[REDACTED]");
896 }
897
898 #[test]
899 fn mock_vault_list_keys_sorted() {
900 let vault = MockVaultProvider::new()
901 .with_secret("B_KEY", "v2")
902 .with_secret("A_KEY", "v1")
903 .with_secret("C_KEY", "v3");
904 let mut keys = vault.list_keys();
905 keys.sort_unstable();
906 assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
907 }
908
909 #[test]
910 fn mock_vault_list_keys_empty() {
911 let vault = MockVaultProvider::new();
912 assert!(vault.list_keys().is_empty());
913 }
914
915 #[allow(unsafe_code)]
916 #[test]
917 fn env_vault_list_keys_filters_zeph_secret_prefix() {
918 let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
919 unsafe { std::env::set_var(key, "v") };
920 let vault = EnvVaultProvider;
921 let keys = vault.list_keys();
922 assert!(keys.contains(&key.to_owned()));
923 unsafe { std::env::remove_var(key) };
924 }
925}
926
927#[cfg(test)]
928mod age_tests {
929 use std::io::Write as _;
930
931 use age::secrecy::ExposeSecret;
932
933 use super::*;
934
935 fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
936 let recipient = identity.to_public();
937 let encryptor =
938 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
939 .expect("encryptor creation");
940 let mut encrypted = vec![];
941 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
942 writer
943 .write_all(json.to_string().as_bytes())
944 .expect("write plaintext");
945 writer.finish().expect("finish encryption");
946 encrypted
947 }
948
949 fn write_temp_files(
950 identity: &age::x25519::Identity,
951 ciphertext: &[u8],
952 ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
953 let dir = tempfile::tempdir().expect("tempdir");
954 let key_path = dir.path().join("key.txt");
955 let vault_path = dir.path().join("secrets.age");
956 std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
957 std::fs::write(&vault_path, ciphertext).expect("write vault");
958 (dir, key_path, vault_path)
959 }
960
961 #[tokio::test]
962 async fn age_vault_returns_existing_secret() {
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("KEY").await.unwrap();
970 assert_eq!(result.as_deref(), Some("value"));
971 }
972
973 #[tokio::test]
974 async fn age_vault_returns_none_for_missing() {
975 let identity = age::x25519::Identity::generate();
976 let json = serde_json::json!({"KEY": "value"});
977 let encrypted = encrypt_json(&identity, &json);
978 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
979
980 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
981 let result = vault.get_secret("MISSING").await.unwrap();
982 assert!(result.is_none());
983 }
984
985 #[test]
986 fn age_vault_bad_key_file() {
987 let err = AgeVaultProvider::new(
988 Path::new("/nonexistent/key.txt"),
989 Path::new("/nonexistent/vault.age"),
990 )
991 .unwrap_err();
992 assert!(matches!(err, AgeVaultError::KeyRead(_)));
993 }
994
995 #[test]
996 fn age_vault_bad_key_parse() {
997 let dir = tempfile::tempdir().unwrap();
998 let key_path = dir.path().join("bad-key.txt");
999 std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
1000
1001 let vault_path = dir.path().join("vault.age");
1002 std::fs::write(&vault_path, b"dummy").unwrap();
1003
1004 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1005 assert!(matches!(err, AgeVaultError::KeyParse(_)));
1006 }
1007
1008 #[test]
1009 fn age_vault_bad_vault_file() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let identity = age::x25519::Identity::generate();
1012 let key_path = dir.path().join("key.txt");
1013 std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
1014
1015 let err =
1016 AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
1017 assert!(matches!(err, AgeVaultError::VaultRead(_)));
1018 }
1019
1020 #[test]
1021 fn age_vault_wrong_key() {
1022 let identity = age::x25519::Identity::generate();
1023 let wrong_identity = age::x25519::Identity::generate();
1024 let json = serde_json::json!({"KEY": "value"});
1025 let encrypted = encrypt_json(&identity, &json);
1026 let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
1027
1028 let dir2 = tempfile::tempdir().unwrap();
1029 let wrong_key_path = dir2.path().join("wrong-key.txt");
1030 std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
1031
1032 let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
1033 assert!(matches!(err, AgeVaultError::Decrypt(_)));
1034 }
1035
1036 #[test]
1037 fn age_vault_invalid_json() {
1038 let identity = age::x25519::Identity::generate();
1039 let recipient = identity.to_public();
1040 let encryptor =
1041 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1042 .expect("encryptor");
1043 let mut encrypted = vec![];
1044 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1045 writer.write_all(b"not json").expect("write");
1046 writer.finish().expect("finish");
1047
1048 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1049 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1050 assert!(matches!(err, AgeVaultError::Json(_)));
1051 }
1052
1053 #[test]
1054 fn age_vault_debug_impl() {
1055 let identity = age::x25519::Identity::generate();
1056 let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
1057 let encrypted = encrypt_json(&identity, &json);
1058 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1059
1060 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1061 let debug = format!("{vault:?}");
1062 assert!(debug.contains("AgeVaultProvider"));
1063 assert!(debug.contains("[2 secrets]"));
1064 assert!(!debug.contains("value1"));
1065 }
1066
1067 #[tokio::test]
1068 async fn age_vault_key_file_with_comments() {
1069 let identity = age::x25519::Identity::generate();
1070 let json = serde_json::json!({"KEY": "value"});
1071 let encrypted = encrypt_json(&identity, &json);
1072 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1073
1074 let key_with_comments = format!(
1075 "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
1076 identity.to_public(),
1077 identity.to_string().expose_secret()
1078 );
1079 std::fs::write(&key_path, &key_with_comments).unwrap();
1080
1081 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1082 let result = vault.get_secret("KEY").await.unwrap();
1083 assert_eq!(result.as_deref(), Some("value"));
1084 }
1085
1086 #[test]
1087 fn age_vault_key_file_only_comments() {
1088 let dir = tempfile::tempdir().unwrap();
1089 let key_path = dir.path().join("comments-only.txt");
1090 std::fs::write(&key_path, "# comment\n# another\n").unwrap();
1091 let vault_path = dir.path().join("vault.age");
1092 std::fs::write(&vault_path, b"dummy").unwrap();
1093
1094 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1095 assert!(matches!(err, AgeVaultError::KeyParse(_)));
1096 }
1097
1098 #[test]
1099 fn age_vault_error_display() {
1100 let key_err =
1101 AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1102 assert!(key_err.to_string().contains("failed to read key file"));
1103
1104 let parse_err = AgeVaultError::KeyParse("bad key".into());
1105 assert!(
1106 parse_err
1107 .to_string()
1108 .contains("failed to parse age identity")
1109 );
1110
1111 let vault_err =
1112 AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1113 assert!(vault_err.to_string().contains("failed to read vault file"));
1114
1115 let enc_err = AgeVaultError::Encrypt("bad".into());
1116 assert!(enc_err.to_string().contains("age encryption failed"));
1117
1118 let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
1119 std::io::ErrorKind::PermissionDenied,
1120 "test",
1121 ));
1122 assert!(write_err.to_string().contains("failed to write vault file"));
1123 }
1124
1125 #[test]
1126 fn age_vault_set_and_list_keys() {
1127 let identity = age::x25519::Identity::generate();
1128 let json = serde_json::json!({"A": "1"});
1129 let encrypted = encrypt_json(&identity, &json);
1130 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1131
1132 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1133 vault.set_secret_mut("B".to_owned(), "2".to_owned());
1134 vault.set_secret_mut("C".to_owned(), "3".to_owned());
1135
1136 let keys = vault.list_keys();
1137 assert_eq!(keys, vec!["A", "B", "C"]);
1138 }
1139
1140 #[test]
1141 fn age_vault_remove_secret() {
1142 let identity = age::x25519::Identity::generate();
1143 let json = serde_json::json!({"X": "val", "Y": "val2"});
1144 let encrypted = encrypt_json(&identity, &json);
1145 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1146
1147 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1148 assert!(vault.remove_secret_mut("X"));
1149 assert!(!vault.remove_secret_mut("NONEXISTENT"));
1150 assert_eq!(vault.list_keys(), vec!["Y"]);
1151 }
1152
1153 #[tokio::test]
1154 async fn age_vault_save_roundtrip() {
1155 let identity = age::x25519::Identity::generate();
1156 let json = serde_json::json!({"ORIG": "value"});
1157 let encrypted = encrypt_json(&identity, &json);
1158 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1159
1160 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1161 vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
1162 vault.save().unwrap();
1163
1164 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1165 let result = reloaded.get_secret("NEW_KEY").await.unwrap();
1166 assert_eq!(result.as_deref(), Some("new_value"));
1167
1168 let orig = reloaded.get_secret("ORIG").await.unwrap();
1169 assert_eq!(orig.as_deref(), Some("value"));
1170 }
1171
1172 #[test]
1173 fn age_vault_get_method_returns_str() {
1174 let identity = age::x25519::Identity::generate();
1175 let json = serde_json::json!({"FOO": "bar"});
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("FOO"), Some("bar"));
1181 assert_eq!(vault.get("MISSING"), None);
1182 }
1183
1184 #[test]
1185 fn age_vault_empty_secret_value() {
1186 let identity = age::x25519::Identity::generate();
1187 let json = serde_json::json!({"EMPTY": ""});
1188 let encrypted = encrypt_json(&identity, &json);
1189 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1190
1191 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1192 assert_eq!(vault.get("EMPTY"), Some(""));
1193 }
1194
1195 #[test]
1196 fn age_vault_init_vault() {
1197 let dir = tempfile::tempdir().unwrap();
1198 AgeVaultProvider::init_vault(dir.path()).unwrap();
1199
1200 let key_path = dir.path().join("vault-key.txt");
1201 let vault_path = dir.path().join("secrets.age");
1202 assert!(key_path.exists());
1203 assert!(vault_path.exists());
1204
1205 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1206 assert_eq!(vault.list_keys(), Vec::<&str>::new());
1207 }
1208
1209 #[tokio::test]
1210 async fn age_vault_keys_sorted_after_roundtrip() {
1211 let identity = age::x25519::Identity::generate();
1212 let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
1214 let encrypted = encrypt_json(&identity, &json);
1215 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1216
1217 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1218 let keys = vault.list_keys();
1219 assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
1220 }
1221
1222 #[test]
1223 fn age_vault_save_preserves_key_order() {
1224 let identity = age::x25519::Identity::generate();
1225 let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
1226 let encrypted = encrypt_json(&identity, &json);
1227 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1228
1229 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1230 vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
1231 vault.save().unwrap();
1232
1233 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1234 let keys = reloaded.list_keys();
1235 assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
1236 }
1237
1238 #[test]
1239 fn age_vault_decrypt_returns_btreemap_sorted() {
1240 let identity = age::x25519::Identity::generate();
1241 let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
1243 let recipient = identity.to_public();
1244 let encryptor =
1245 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1246 .expect("encryptor");
1247 let mut encrypted = vec![];
1248 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1249 writer.write_all(json_str.as_bytes()).expect("write");
1250 writer.finish().expect("finish");
1251
1252 let ciphertext = encrypted;
1253 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1254 let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
1255 assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
1257 }
1258
1259 #[test]
1260 fn age_vault_into_iter_consumes_all_entries() {
1261 let identity = age::x25519::Identity::generate();
1264 let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
1265 let encrypted = encrypt_json(&identity, &json);
1266 let ciphertext = encrypted;
1267 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1268
1269 let mut pairs: Vec<(String, String)> = secrets
1270 .into_iter()
1271 .map(|(k, v)| (k, v.as_str().to_owned()))
1272 .collect();
1273 pairs.sort_by(|a, b| a.0.cmp(&b.0));
1274
1275 assert_eq!(pairs.len(), 3);
1276 assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
1277 assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
1278 assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
1279 }
1280
1281 use proptest::prelude::*;
1282
1283 proptest! {
1284 #[test]
1285 fn secret_value_roundtrip(s in ".*") {
1286 let secret = Secret::new(s.clone());
1287 assert_eq!(secret.expose(), s.as_str());
1288 }
1289
1290 #[test]
1291 fn secret_debug_always_redacted(s in ".*") {
1292 let secret = Secret::new(s);
1293 assert_eq!(format!("{secret:?}"), "[REDACTED]");
1294 }
1295
1296 #[test]
1297 fn secret_display_always_redacted(s in ".*") {
1298 let secret = Secret::new(s);
1299 assert_eq!(format!("{secret}"), "[REDACTED]");
1300 }
1301 }
1302}