Skip to main content

cuenv_secrets/
batch.rs

1//! Batch secret resolution with concurrent provider execution
2//!
3//! This module provides efficient batch resolution of secrets across multiple
4//! providers with:
5//! - Concurrent resolution across different providers
6//! - Provider-specific batch APIs where available (AWS, 1Password)
7//! - Automatic fingerprinting for cache key inclusion
8//! - Secure memory handling via [`SecureSecret`]
9
10use crate::{
11    BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
12};
13use futures::future::try_join_all;
14use std::collections::HashMap;
15
16/// Configuration for batch resolution.
17#[derive(Debug, Clone, Default)]
18pub struct BatchConfig {
19    /// Salt configuration for fingerprinting secrets with `cache_key: true`.
20    pub salt_config: SaltConfig,
21}
22
23impl BatchConfig {
24    /// Create a new batch config with the given salt configuration.
25    #[must_use]
26    pub const fn new(salt_config: SaltConfig) -> Self {
27        Self { salt_config }
28    }
29
30    /// Create a batch config from an optional salt string.
31    #[must_use]
32    pub const fn from_salt(salt: Option<String>) -> Self {
33        Self {
34            salt_config: SaltConfig::new(salt),
35        }
36    }
37}
38
39/// Multi-provider batch resolver.
40///
41/// Groups secrets by provider type and resolves concurrently across providers,
42/// while using each provider's optimal batch strategy internally.
43///
44/// # Example
45///
46/// ```ignore
47/// use cuenv_secrets::{BatchResolver, BatchConfig, SaltConfig};
48///
49/// let config = BatchConfig::new(SaltConfig::new(Some("my-salt".to_string())));
50/// let mut resolver = BatchResolver::new(config);
51///
52/// // Register resolvers
53/// resolver.add_resolver(&env_resolver);
54/// resolver.add_resolver(&aws_resolver);
55///
56/// // Resolve all secrets (groups by provider for optimal batch handling)
57/// let secrets = resolver.resolve(&secret_specs).await?;
58/// ```
59pub struct BatchResolver<'a> {
60    /// Provider name -> resolver
61    resolvers: HashMap<&'static str, &'a dyn SecretResolver>,
62    /// Configuration for batch resolution
63    config: BatchConfig,
64}
65
66impl<'a> BatchResolver<'a> {
67    /// Create a new batch resolver with the given configuration.
68    #[must_use]
69    pub fn new(config: BatchConfig) -> Self {
70        Self {
71            resolvers: HashMap::new(),
72            config,
73        }
74    }
75
76    /// Register a resolver for its provider.
77    ///
78    /// The resolver's [`provider_name`](SecretResolver::provider_name) is used
79    /// as the key for grouping secrets.
80    pub fn add_resolver(&mut self, resolver: &'a dyn SecretResolver) {
81        self.resolvers.insert(resolver.provider_name(), resolver);
82    }
83
84    /// Get the number of registered resolvers.
85    #[must_use]
86    pub fn resolver_count(&self) -> usize {
87        self.resolvers.len()
88    }
89
90    /// Resolve all secrets, grouping by provider for optimal batch handling.
91    ///
92    /// # Arguments
93    ///
94    /// * `secrets` - Map of secret names to (spec, `provider_name`) tuples
95    ///
96    /// # Errors
97    ///
98    /// Returns error if:
99    /// - A required provider is not registered
100    /// - Salt is missing when secrets have `cache_key: true`
101    /// - Any secret resolution fails
102    pub async fn resolve_all(
103        &self,
104        secrets: &HashMap<String, (SecretSpec, &'static str)>,
105    ) -> Result<BatchSecrets, SecretError> {
106        // Check salt requirements upfront
107        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        // Group secrets by provider
113        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        // Resolve each provider's secrets concurrently
122        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        // Merge results and compute fingerprints
138        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                // Compute fingerprint if this secret affects cache keys
142                let fingerprint = secrets.get(&name).and_then(|(spec, _)| {
143                    if !spec.cache_key {
144                        return None;
145                    }
146                    // Warn if secret is too short
147                    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/// Convenience function for single-provider batch resolution.
170///
171/// Resolves all secrets using a single resolver and computes fingerprints
172/// for secrets with `cache_key: true`.
173///
174/// # Arguments
175///
176/// * `resolver` - The secret resolver to use
177/// * `secrets` - Map of secret names to their specifications
178/// * `salt_config` - Salt configuration for fingerprinting
179///
180/// # Errors
181///
182/// Returns error if:
183/// - Salt is missing when secrets have `cache_key: true`
184/// - Any secret resolution fails
185///
186/// # Example
187///
188/// ```ignore
189/// use cuenv_secrets::{resolve_batch, SaltConfig, EnvSecretResolver};
190///
191/// let resolver = EnvSecretResolver::new();
192/// let salt = SaltConfig::new(Some("my-salt".to_string()));
193///
194/// let secrets = resolve_batch(&resolver, &specs, &salt).await?;
195/// ```
196#[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    // Check salt requirements
203    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    // Resolve all secrets using the resolver's batch method
209    let batch_results = resolver.resolve_batch(secrets).await?;
210
211    // Build BatchSecrets with fingerprints
212    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            // Warn if secret is too short
219            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    // ==========================================================================
245    // BatchConfig tests
246    // ==========================================================================
247
248    #[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    // ==========================================================================
290    // BatchResolver tests
291    // ==========================================================================
292
293    #[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        // Adding the same resolver type replaces (uses same provider_name key)
317        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(); // No salt
353        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    // ==========================================================================
366    // resolve_batch function tests
367    // ==========================================================================
368
369    #[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(); // No salt
385
386        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        // SecretSpec without cache_key doesn't require salt
395        secrets.insert("TEST".to_string(), SecretSpec::new("NONEXISTENT_VAR"));
396        let salt = SaltConfig::default();
397
398        // This may fail if the env var doesn't exist, but it shouldn't fail due to missing salt
399        let result = resolve_batch(&resolver, &secrets, &salt).await;
400        // Either succeeds or fails for other reasons (missing env var), not missing salt
401        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        // Set an env var for testing
410        // SAFETY: Test runs in isolation
411        #[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        // Cleanup
428        #[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        // Set an env var for testing
437        // SAFETY: Test runs in isolation
438        #[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        // Without cache_key, no fingerprint is computed
446        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        // Cleanup
456        #[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        // SAFETY: Test runs in isolation
465        #[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        // Cleanup
481        #[allow(unsafe_code)]
482        unsafe {
483            std::env::remove_var("BATCH_MULTI_1");
484            std::env::remove_var("BATCH_MULTI_2");
485        }
486    }
487}