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 const fn new(salt_config: SaltConfig) -> Self {
27 Self { salt_config }
28 }
29
30 #[must_use]
32 pub const 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 = secrets.get(&name).and_then(|(spec, _)| {
143 if !spec.cache_key {
144 return None;
145 }
146 if secure_value.len() < 4 {
148 tracing::warn!(
149 secret = %name,
150 len = secure_value.len(),
151 "Secret is too short for safe cache key inclusion"
152 );
153 }
154 Some(compute_secret_fingerprint(
155 &name,
156 secure_value.expose(),
157 self.config.salt_config.write_salt().unwrap_or(""),
158 ))
159 });
160
161 batch.insert(name, secure_value, fingerprint);
162 }
163 }
164
165 Ok(batch)
166 }
167}
168
169#[allow(clippy::implicit_hasher)]
197pub async fn resolve_batch<R: SecretResolver>(
198 resolver: &R,
199 secrets: &HashMap<String, SecretSpec>,
200 salt_config: &SaltConfig,
201) -> Result<BatchSecrets, SecretError> {
202 let needs_salt = secrets.values().any(|s| s.cache_key);
204 if needs_salt && !salt_config.has_salt() {
205 return Err(SecretError::MissingSalt);
206 }
207
208 let batch_results = resolver.resolve_batch(secrets).await?;
210
211 let mut batch = BatchSecrets::with_capacity(secrets.len());
213 for (name, secure_value) in batch_results {
214 let fingerprint = secrets.get(&name).and_then(|spec| {
215 if !spec.cache_key {
216 return None;
217 }
218 if secure_value.len() < 4 {
220 tracing::warn!(
221 secret = %name,
222 len = secure_value.len(),
223 "Secret is too short for safe cache key inclusion"
224 );
225 }
226 Some(compute_secret_fingerprint(
227 &name,
228 secure_value.expose(),
229 salt_config.write_salt().unwrap_or(""),
230 ))
231 });
232
233 batch.insert(name, secure_value, fingerprint);
234 }
235
236 Ok(batch)
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::EnvSecretResolver;
243
244 #[test]
249 fn test_batch_config_default() {
250 let config = BatchConfig::default();
251 assert!(!config.salt_config.has_salt());
252 }
253
254 #[test]
255 fn test_batch_config_new() {
256 let salt_config = SaltConfig::new(Some("test-salt".to_string()));
257 let config = BatchConfig::new(salt_config);
258 assert!(config.salt_config.has_salt());
259 assert_eq!(config.salt_config.write_salt(), Some("test-salt"));
260 }
261
262 #[test]
263 fn test_batch_config_from_salt() {
264 let config = BatchConfig::from_salt(Some("my-salt".to_string()));
265 assert!(config.salt_config.has_salt());
266 assert_eq!(config.salt_config.write_salt(), Some("my-salt"));
267 }
268
269 #[test]
270 fn test_batch_config_from_salt_none() {
271 let config = BatchConfig::from_salt(None);
272 assert!(!config.salt_config.has_salt());
273 }
274
275 #[test]
276 fn test_batch_config_clone() {
277 let config = BatchConfig::from_salt(Some("cloned-salt".to_string()));
278 let cloned = config.clone();
279 assert!(cloned.salt_config.has_salt());
280 }
281
282 #[test]
283 fn test_batch_config_debug() {
284 let config = BatchConfig::default();
285 let debug_str = format!("{:?}", config);
286 assert!(debug_str.contains("BatchConfig"));
287 }
288
289 #[test]
294 fn test_batch_resolver_new() {
295 let config = BatchConfig::default();
296 let resolver = BatchResolver::new(config);
297 assert_eq!(resolver.resolver_count(), 0);
298 }
299
300 #[test]
301 fn test_batch_resolver_add_resolver() {
302 let config = BatchConfig::default();
303 let mut resolver = BatchResolver::new(config);
304 let env_resolver = EnvSecretResolver::new();
305
306 resolver.add_resolver(&env_resolver);
307 assert_eq!(resolver.resolver_count(), 1);
308 }
309
310 #[test]
311 fn test_batch_resolver_add_multiple_resolvers() {
312 let config = BatchConfig::default();
313 let mut resolver = BatchResolver::new(config);
314 let env_resolver = EnvSecretResolver::new();
315
316 resolver.add_resolver(&env_resolver);
318 resolver.add_resolver(&env_resolver);
319 assert_eq!(resolver.resolver_count(), 1);
320 }
321
322 #[tokio::test]
323 async fn test_batch_resolver_resolve_all_empty() {
324 let config = BatchConfig::default();
325 let resolver = BatchResolver::new(config);
326 let secrets: HashMap<String, (SecretSpec, &'static str)> = HashMap::new();
327
328 let result = resolver.resolve_all(&secrets).await.unwrap();
329 assert!(result.is_empty());
330 }
331
332 #[tokio::test]
333 async fn test_batch_resolver_missing_provider() {
334 let config = BatchConfig::default();
335 let resolver = BatchResolver::new(config);
336
337 let mut secrets = HashMap::new();
338 secrets.insert(
339 "TEST".to_string(),
340 (SecretSpec::new("test"), "unknown_provider"),
341 );
342
343 let result = resolver.resolve_all(&secrets).await;
344 assert!(matches!(
345 result,
346 Err(SecretError::UnsupportedResolver { .. })
347 ));
348 }
349
350 #[tokio::test]
351 async fn test_batch_resolver_missing_salt_for_cache_key() {
352 let config = BatchConfig::default(); let resolver = BatchResolver::new(config);
354
355 let mut secrets = HashMap::new();
356 secrets.insert(
357 "TEST".to_string(),
358 (SecretSpec::with_cache_key("test"), "env"),
359 );
360
361 let result = resolver.resolve_all(&secrets).await;
362 assert!(matches!(result, Err(SecretError::MissingSalt)));
363 }
364
365 #[tokio::test]
370 async fn test_resolve_batch_empty() {
371 let resolver = EnvSecretResolver::new();
372 let secrets = HashMap::new();
373 let salt = SaltConfig::default();
374
375 let result = resolve_batch(&resolver, &secrets, &salt).await.unwrap();
376 assert!(result.is_empty());
377 }
378
379 #[tokio::test]
380 async fn test_resolve_batch_missing_salt() {
381 let resolver = EnvSecretResolver::new();
382 let mut secrets = HashMap::new();
383 secrets.insert("TEST".to_string(), SecretSpec::with_cache_key("TEST_VAR"));
384 let salt = SaltConfig::default(); let result = resolve_batch(&resolver, &secrets, &salt).await;
387 assert!(matches!(result, Err(SecretError::MissingSalt)));
388 }
389
390 #[tokio::test]
391 async fn test_resolve_batch_no_cache_key_no_salt_ok() {
392 let resolver = EnvSecretResolver::new();
393 let mut secrets = HashMap::new();
394 secrets.insert("TEST".to_string(), SecretSpec::new("NONEXISTENT_VAR"));
396 let salt = SaltConfig::default();
397
398 let result = resolve_batch(&resolver, &secrets, &salt).await;
400 assert!(
402 !matches!(result, Err(SecretError::MissingSalt)),
403 "Should not require salt for non-cache-key secrets"
404 );
405 }
406
407 #[tokio::test]
408 async fn test_resolve_batch_with_salt_and_cache_key() {
409 #[allow(unsafe_code)]
412 unsafe {
413 std::env::set_var("BATCH_TEST_SECRET", "test_value");
414 }
415
416 let resolver = EnvSecretResolver::new();
417 let mut secrets = HashMap::new();
418 secrets.insert(
419 "my_secret".to_string(),
420 SecretSpec::with_cache_key("BATCH_TEST_SECRET"),
421 );
422 let salt = SaltConfig::new(Some("test-salt".to_string()));
423
424 let result = resolve_batch(&resolver, &secrets, &salt).await.unwrap();
425 assert!(!result.is_empty());
426
427 #[allow(unsafe_code)]
429 unsafe {
430 std::env::remove_var("BATCH_TEST_SECRET");
431 }
432 }
433
434 #[tokio::test]
435 async fn test_resolve_batch_without_cache_key() {
436 #[allow(unsafe_code)]
439 unsafe {
440 std::env::set_var("BATCH_TEST_NO_CACHE", "another_value");
441 }
442
443 let resolver = EnvSecretResolver::new();
444 let mut secrets = HashMap::new();
445 secrets.insert(
447 "my_secret".to_string(),
448 SecretSpec::new("BATCH_TEST_NO_CACHE"),
449 );
450 let salt = SaltConfig::default();
451
452 let result = resolve_batch(&resolver, &secrets, &salt).await.unwrap();
453 assert!(!result.is_empty());
454
455 #[allow(unsafe_code)]
457 unsafe {
458 std::env::remove_var("BATCH_TEST_NO_CACHE");
459 }
460 }
461
462 #[tokio::test]
463 async fn test_resolve_batch_multiple_secrets() {
464 #[allow(unsafe_code)]
466 unsafe {
467 std::env::set_var("BATCH_MULTI_1", "value1");
468 std::env::set_var("BATCH_MULTI_2", "value2");
469 }
470
471 let resolver = EnvSecretResolver::new();
472 let mut secrets = HashMap::new();
473 secrets.insert("secret1".to_string(), SecretSpec::new("BATCH_MULTI_1"));
474 secrets.insert("secret2".to_string(), SecretSpec::new("BATCH_MULTI_2"));
475 let salt = SaltConfig::default();
476
477 let result = resolve_batch(&resolver, &secrets, &salt).await.unwrap();
478 assert_eq!(result.len(), 2);
479
480 #[allow(unsafe_code)]
482 unsafe {
483 std::env::remove_var("BATCH_MULTI_1");
484 std::env::remove_var("BATCH_MULTI_2");
485 }
486 }
487}