1use crate::{
11 BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
12};
13use futures::future::try_join_all;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Default)]
18pub struct BatchConfig {
19 pub salt_config: SaltConfig,
21}
22
23impl BatchConfig {
24 #[must_use]
26 pub fn new(salt_config: SaltConfig) -> Self {
27 Self { salt_config }
28 }
29
30 #[must_use]
32 pub fn from_salt(salt: Option<String>) -> Self {
33 Self {
34 salt_config: SaltConfig::new(salt),
35 }
36 }
37}
38
39pub struct BatchResolver<'a> {
60 resolvers: HashMap<&'static str, &'a dyn SecretResolver>,
62 config: BatchConfig,
64}
65
66impl<'a> BatchResolver<'a> {
67 #[must_use]
69 pub fn new(config: BatchConfig) -> Self {
70 Self {
71 resolvers: HashMap::new(),
72 config,
73 }
74 }
75
76 pub fn add_resolver(&mut self, resolver: &'a dyn SecretResolver) {
81 self.resolvers.insert(resolver.provider_name(), resolver);
82 }
83
84 #[must_use]
86 pub fn resolver_count(&self) -> usize {
87 self.resolvers.len()
88 }
89
90 pub async fn resolve_all(
103 &self,
104 secrets: &HashMap<String, (SecretSpec, &'static str)>,
105 ) -> Result<BatchSecrets, SecretError> {
106 let needs_salt = secrets.values().any(|(spec, _)| spec.cache_key);
108 if needs_salt && !self.config.salt_config.has_salt() {
109 return Err(SecretError::MissingSalt);
110 }
111
112 let mut by_provider: HashMap<&'static str, HashMap<String, SecretSpec>> = HashMap::new();
114 for (name, (spec, provider)) in secrets {
115 by_provider
116 .entry(*provider)
117 .or_default()
118 .insert(name.clone(), spec.clone());
119 }
120
121 let provider_futures: Vec<_> = by_provider
123 .into_iter()
124 .map(|(provider, provider_secrets)| async move {
125 let secret_resolver = self.resolvers.get(provider).ok_or_else(|| {
126 SecretError::UnsupportedResolver {
127 resolver: provider.to_string(),
128 }
129 })?;
130 let batch_results = secret_resolver.resolve_batch(&provider_secrets).await?;
131 Ok::<_, SecretError>((provider, batch_results))
132 })
133 .collect();
134
135 let provider_results = try_join_all(provider_futures).await?;
136
137 let mut batch = BatchSecrets::with_capacity(secrets.len());
139 for (_provider, batch_result) in provider_results {
140 for (name, secure_value) in batch_result {
141 let fingerprint = if let Some((spec, _)) = secrets.get(&name) {
143 if spec.cache_key {
144 if secure_value.len() < 4 {
146 tracing::warn!(
147 secret = %name,
148 len = secure_value.len(),
149 "Secret is too short for safe cache key inclusion"
150 );
151 }
152 Some(compute_secret_fingerprint(
153 &name,
154 secure_value.expose(),
155 self.config.salt_config.write_salt().unwrap_or(""),
156 ))
157 } else {
158 None
159 }
160 } else {
161 None
162 };
163
164 batch.insert(name, secure_value, fingerprint);
165 }
166 }
167
168 Ok(batch)
169 }
170}
171
172#[allow(clippy::implicit_hasher)]
200pub async fn resolve_batch<R: SecretResolver>(
201 resolver: &R,
202 secrets: &HashMap<String, SecretSpec>,
203 salt_config: &SaltConfig,
204) -> Result<BatchSecrets, SecretError> {
205 let needs_salt = secrets.values().any(|s| s.cache_key);
207 if needs_salt && !salt_config.has_salt() {
208 return Err(SecretError::MissingSalt);
209 }
210
211 let batch_results = resolver.resolve_batch(secrets).await?;
213
214 let mut batch = BatchSecrets::with_capacity(secrets.len());
216 for (name, secure_value) in batch_results {
217 let fingerprint = if let Some(spec) = secrets.get(&name) {
218 if spec.cache_key {
219 if secure_value.len() < 4 {
221 tracing::warn!(
222 secret = %name,
223 len = secure_value.len(),
224 "Secret is too short for safe cache key inclusion"
225 );
226 }
227 Some(compute_secret_fingerprint(
228 &name,
229 secure_value.expose(),
230 salt_config.write_salt().unwrap_or(""),
231 ))
232 } else {
233 None
234 }
235 } else {
236 None
237 };
238
239 batch.insert(name, secure_value, fingerprint);
240 }
241
242 Ok(batch)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use crate::EnvSecretResolver;
249
250 #[tokio::test]
251 async fn test_resolve_batch_empty() {
252 let resolver = EnvSecretResolver::new();
253 let secrets = HashMap::new();
254 let salt = SaltConfig::default();
255
256 let result = resolve_batch(&resolver, &secrets, &salt).await.unwrap();
257 assert!(result.is_empty());
258 }
259
260 #[tokio::test]
261 async fn test_resolve_batch_missing_salt() {
262 let resolver = EnvSecretResolver::new();
263 let mut secrets = HashMap::new();
264 secrets.insert("TEST".to_string(), SecretSpec::with_cache_key("TEST_VAR"));
265 let salt = SaltConfig::default(); let result = resolve_batch(&resolver, &secrets, &salt).await;
268 assert!(matches!(result, Err(SecretError::MissingSalt)));
269 }
270
271 #[tokio::test]
272 async fn test_batch_resolver_missing_provider() {
273 let config = BatchConfig::default();
274 let resolver = BatchResolver::new(config);
275
276 let mut secrets = HashMap::new();
277 secrets.insert(
278 "TEST".to_string(),
279 (SecretSpec::new("test"), "unknown_provider"),
280 );
281
282 let result = resolver.resolve_all(&secrets).await;
283 assert!(matches!(
284 result,
285 Err(SecretError::UnsupportedResolver { .. })
286 ));
287 }
288
289 #[tokio::test]
290 async fn test_batch_config_from_salt() {
291 let config = BatchConfig::from_salt(Some("my-salt".to_string()));
292 assert!(config.salt_config.has_salt());
293 assert_eq!(config.salt_config.write_salt(), Some("my-salt"));
294 }
295}