hara_native/kernel/
secret.rs1use crate::snapshot::SecretRequirement;
7use std::collections::BTreeMap;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct SecretDescriptor {
11 pub id: String,
12 pub provider: String,
13 pub version: Option<String>,
14}
15
16pub trait SecretCatalog {
17 fn describe(&self, id: &str) -> Result<Option<SecretDescriptor>, String>;
18}
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ResolvedSecret {
22 pub id: String,
23 pub provider: String,
24 pub version: Option<String>,
25 pub purpose: String,
26}
27
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct ResolvedSecrets {
30 entries: BTreeMap<String, ResolvedSecret>,
31}
32
33impl ResolvedSecrets {
34 pub fn resolve(
35 requirements: &[SecretRequirement],
36 catalog: &dyn SecretCatalog,
37 ) -> Result<Self, String> {
38 let mut entries = BTreeMap::new();
39 for requirement in requirements {
40 let Some(descriptor) = catalog.describe(&requirement.id)? else {
41 if requirement.required {
42 return Err(format!("secret/required-unavailable: {}", requirement.id));
43 }
44 continue;
45 };
46 if let Some(expected) = requirement.version.as_deref() {
47 if descriptor.version.as_deref() != Some(expected) {
48 return Err(format!(
49 "secret/provider-version-mismatch: {} expected {expected}, received {}",
50 requirement.id,
51 descriptor.version.as_deref().unwrap_or("unspecified")
52 ));
53 }
54 }
55 entries.insert(
56 requirement.id.clone(),
57 ResolvedSecret {
58 id: requirement.id.clone(),
59 provider: descriptor.provider,
60 version: descriptor.version,
61 purpose: requirement.purpose.clone(),
62 },
63 );
64 }
65 Ok(Self { entries })
66 }
67
68 pub fn get(&self, id: &str) -> Option<&ResolvedSecret> {
69 self.entries.get(id)
70 }
71
72 pub fn ids(&self) -> impl Iterator<Item = &str> {
73 self.entries.keys().map(String::as_str)
74 }
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 struct Catalog(BTreeMap<String, SecretDescriptor>);
82
83 impl SecretCatalog for Catalog {
84 fn describe(&self, id: &str) -> Result<Option<SecretDescriptor>, String> {
85 Ok(self.0.get(id).cloned())
86 }
87 }
88
89 fn requirement(required: bool, version: Option<&str>) -> SecretRequirement {
90 SecretRequirement {
91 id: "payments".into(),
92 purpose: "sign payment requests".into(),
93 required,
94 version: version.map(str::to_owned),
95 }
96 }
97
98 #[test]
99 fn resolves_descriptors_without_receiving_secret_material() {
100 let catalog = Catalog(BTreeMap::from([(
101 "payments".into(),
102 SecretDescriptor {
103 id: "payments".into(),
104 provider: "kms".into(),
105 version: Some("7".into()),
106 },
107 )]));
108 let resolved = ResolvedSecrets::resolve(&[requirement(true, Some("7"))], &catalog).unwrap();
109 assert_eq!(resolved.get("payments").unwrap().provider, "kms");
110 assert_eq!(resolved.ids().collect::<Vec<_>>(), ["payments"]);
111 }
112
113 #[test]
114 fn required_missing_or_wrong_version_prevents_publication() {
115 let empty = Catalog(BTreeMap::new());
116 assert!(ResolvedSecrets::resolve(&[requirement(true, None)], &empty)
117 .unwrap_err()
118 .contains("required-unavailable"));
119 assert!(
120 ResolvedSecrets::resolve(&[requirement(false, None)], &empty)
121 .unwrap()
122 .ids()
123 .next()
124 .is_none()
125 );
126
127 let catalog = Catalog(BTreeMap::from([(
128 "payments".into(),
129 SecretDescriptor {
130 id: "payments".into(),
131 provider: "kms".into(),
132 version: Some("8".into()),
133 },
134 )]));
135 assert!(
136 ResolvedSecrets::resolve(&[requirement(true, Some("7"))], &catalog)
137 .unwrap_err()
138 .contains("version-mismatch")
139 );
140 }
141}