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 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 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
57/// let secrets = resolver.resolve_all(&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 = if let Some((spec, _)) = secrets.get(&name) {
143                    if spec.cache_key {
144                        // Warn if secret is too short
145                        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/// Convenience function for single-provider batch resolution.
173///
174/// Resolves all secrets using a single resolver and computes fingerprints
175/// for secrets with `cache_key: true`.
176///
177/// # Arguments
178///
179/// * `resolver` - The secret resolver to use
180/// * `secrets` - Map of secret names to their specifications
181/// * `salt_config` - Salt configuration for fingerprinting
182///
183/// # Errors
184///
185/// Returns error if:
186/// - Salt is missing when secrets have `cache_key: true`
187/// - Any secret resolution fails
188///
189/// # Example
190///
191/// ```ignore
192/// use cuenv_secrets::{resolve_batch, SaltConfig, EnvSecretResolver};
193///
194/// let resolver = EnvSecretResolver::new();
195/// let salt = SaltConfig::new(Some("my-salt".to_string()));
196///
197/// let secrets = resolve_batch(&resolver, &specs, &salt).await?;
198/// ```
199#[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    // Check salt requirements
206    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    // Resolve all secrets using the resolver's batch method
212    let batch_results = resolver.resolve_batch(secrets).await?;
213
214    // Build BatchSecrets with fingerprints
215    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                // Warn if secret is too short
220                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(); // No salt
266
267        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}