Skip to main content

cuenv_secrets/
resolved.rs

1//! Resolved secrets with fingerprinting support
2
3use crate::{
4    BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
5};
6use std::collections::HashMap;
7
8/// Resolved secrets ready for injection
9#[derive(Debug, Clone, Default)]
10pub struct ResolvedSecrets {
11    /// Secret name -> resolved value
12    pub values: HashMap<String, String>,
13    /// Secret name -> HMAC fingerprint (for cache keys)
14    pub fingerprints: HashMap<String, String>,
15}
16
17impl ResolvedSecrets {
18    /// Create empty resolved secrets
19    #[must_use]
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Resolve secrets using a resolver with salt configuration
25    ///
26    /// # Arguments
27    /// * `resolver` - The secret resolver to use
28    /// * `secrets` - Map of secret names to their configuration
29    /// * `salt_config` - Salt configuration for fingerprinting
30    ///
31    /// # Errors
32    /// Returns error if a secret cannot be resolved or if salt is missing
33    /// when secrets have `cache_key: true`
34    pub async fn resolve<R: SecretResolver>(
35        resolver: &R,
36        secrets: &HashMap<String, SecretSpec>,
37        salt_config: &SaltConfig,
38    ) -> Result<Self, SecretError> {
39        let mut values = HashMap::new();
40        let mut fingerprints = HashMap::new();
41
42        // Check if any secret requires cache key and salt is missing
43        let needs_salt = secrets.values().any(|c| c.cache_key);
44        if needs_salt && !salt_config.has_salt() {
45            return Err(SecretError::MissingSalt);
46        }
47
48        for (name, spec) in secrets {
49            let value = resolver.resolve(name, spec).await?;
50
51            // Compute fingerprint if secret affects cache
52            if spec.cache_key {
53                // Warn if secret is too short (but don't fail)
54                if value.len() < 4 {
55                    tracing::warn!(
56                        secret = %name,
57                        len = value.len(),
58                        "Secret is too short for safe cache key inclusion"
59                    );
60                }
61
62                // Use write_salt for computing fingerprints (current salt preferred)
63                let fingerprint = compute_secret_fingerprint(
64                    name,
65                    &value,
66                    salt_config.write_salt().unwrap_or(""),
67                );
68                fingerprints.insert(name.clone(), fingerprint);
69            }
70
71            values.insert(name.clone(), value);
72        }
73
74        Ok(Self {
75            values,
76            fingerprints,
77        })
78    }
79
80    /// Create from a `BatchSecrets` instance.
81    ///
82    /// This consumes the batch and converts it to the legacy format.
83    /// Note that this exposes the secret values from the secure storage.
84    #[must_use]
85    pub fn from_batch(batch: BatchSecrets) -> Self {
86        batch.into_resolved_secrets()
87    }
88
89    /// Resolve secrets using batch resolution with a resolver.
90    ///
91    /// This is the preferred method for resolving multiple secrets efficiently.
92    /// It uses the resolver's batch resolution method which may use native
93    /// batch APIs (e.g., AWS `BatchGetSecretValue`, 1Password `Secrets.ResolveAll`).
94    ///
95    /// # Arguments
96    /// * `resolver` - The secret resolver to use
97    /// * `secrets` - Map of secret names to their configuration
98    /// * `salt_config` - Salt configuration for fingerprinting
99    ///
100    /// # Errors
101    /// Returns error if a secret cannot be resolved or if salt is missing
102    /// when secrets have `cache_key: true`
103    pub async fn resolve_batch<R: SecretResolver>(
104        resolver: &R,
105        secrets: &HashMap<String, SecretSpec>,
106        salt_config: &SaltConfig,
107    ) -> Result<Self, SecretError> {
108        let batch = crate::batch::resolve_batch(resolver, secrets, salt_config).await?;
109        Ok(Self::from_batch(batch))
110    }
111
112    /// Check if any secrets were resolved
113    #[must_use]
114    pub fn is_empty(&self) -> bool {
115        self.values.is_empty()
116    }
117
118    /// Get a resolved secret value by name
119    #[must_use]
120    pub fn get(&self, name: &str) -> Option<&str> {
121        self.values.get(name).map(String::as_str)
122    }
123
124    /// Check if a cached fingerprint matches with salt rotation support
125    ///
126    /// During salt rotation, this checks if the cached fingerprint matches
127    /// using either the current or previous salt. This allows cache hits
128    /// during the rotation window.
129    ///
130    /// # Arguments
131    /// * `name` - Secret name
132    /// * `cached_fingerprint` - Fingerprint from cache
133    /// * `salt_config` - Salt configuration with current and optional previous salt
134    ///
135    /// # Returns
136    /// `true` if the fingerprint matches with either salt, `false` otherwise
137    #[must_use]
138    pub fn fingerprint_matches(
139        &self,
140        name: &str,
141        cached_fingerprint: &str,
142        salt_config: &SaltConfig,
143    ) -> bool {
144        let Some(value) = self.values.get(name) else {
145            return false;
146        };
147
148        // Check against current salt
149        if let Some(current) = &salt_config.current {
150            let current_fp = compute_secret_fingerprint(name, value, current);
151            if current_fp == cached_fingerprint {
152                return true;
153            }
154        }
155
156        // Check against previous salt (for rotation window)
157        if let Some(previous) = &salt_config.previous {
158            let previous_fp = compute_secret_fingerprint(name, value, previous);
159            if previous_fp == cached_fingerprint {
160                tracing::debug!(
161                    secret = %name,
162                    "Cache hit using previous salt - rotation in progress"
163                );
164                return true;
165            }
166        }
167
168        false
169    }
170
171    /// Compute fingerprints using both current and previous salts
172    ///
173    /// Returns a tuple of (`current_fingerprint`, `previous_fingerprint`) for cache validation.
174    /// Either may be None if the corresponding salt is not configured.
175    #[must_use]
176    pub fn compute_fingerprints_for_validation(
177        &self,
178        name: &str,
179        salt_config: &SaltConfig,
180    ) -> (Option<String>, Option<String>) {
181        let Some(value) = self.values.get(name) else {
182            return (None, None);
183        };
184
185        let current_fp = salt_config
186            .current
187            .as_ref()
188            .map(|salt| compute_secret_fingerprint(name, value, salt));
189
190        let previous_fp = salt_config
191            .previous
192            .as_ref()
193            .map(|salt| compute_secret_fingerprint(name, value, salt));
194
195        (current_fp, previous_fp)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_resolved_secrets_new_is_empty() {
205        let secrets = ResolvedSecrets::new();
206        assert!(secrets.is_empty());
207        assert!(secrets.values.is_empty());
208        assert!(secrets.fingerprints.is_empty());
209    }
210
211    #[test]
212    fn test_resolved_secrets_default_is_empty() {
213        let secrets = ResolvedSecrets::default();
214        assert!(secrets.is_empty());
215    }
216
217    #[test]
218    fn test_resolved_secrets_get_existing() {
219        let mut secrets = ResolvedSecrets::new();
220        secrets
221            .values
222            .insert("API_KEY".to_string(), "secret123".to_string());
223
224        assert_eq!(secrets.get("API_KEY"), Some("secret123"));
225        assert!(!secrets.is_empty());
226    }
227
228    #[test]
229    fn test_resolved_secrets_get_missing() {
230        let secrets = ResolvedSecrets::new();
231        assert_eq!(secrets.get("NONEXISTENT"), None);
232    }
233
234    #[test]
235    fn test_fingerprint_matches_with_current_salt() {
236        let mut secrets = ResolvedSecrets::new();
237        secrets
238            .values
239            .insert("API_KEY".to_string(), "secret123".to_string());
240
241        let salt_config = SaltConfig::new(Some("my-salt".to_string()));
242        let fingerprint = compute_secret_fingerprint("API_KEY", "secret123", "my-salt");
243
244        assert!(secrets.fingerprint_matches("API_KEY", &fingerprint, &salt_config));
245    }
246
247    #[test]
248    fn test_fingerprint_matches_with_previous_salt() {
249        let mut secrets = ResolvedSecrets::new();
250        secrets
251            .values
252            .insert("API_KEY".to_string(), "secret123".to_string());
253
254        // Salt config with new salt but old fingerprint should still match
255        let salt_config =
256            SaltConfig::with_rotation(Some("new-salt".to_string()), Some("old-salt".to_string()));
257        let old_fingerprint = compute_secret_fingerprint("API_KEY", "secret123", "old-salt");
258
259        assert!(secrets.fingerprint_matches("API_KEY", &old_fingerprint, &salt_config));
260    }
261
262    #[test]
263    fn test_fingerprint_matches_no_match() {
264        let mut secrets = ResolvedSecrets::new();
265        secrets
266            .values
267            .insert("API_KEY".to_string(), "secret123".to_string());
268
269        let salt_config = SaltConfig::new(Some("my-salt".to_string()));
270        let wrong_fingerprint = compute_secret_fingerprint("API_KEY", "wrong-secret", "my-salt");
271
272        assert!(!secrets.fingerprint_matches("API_KEY", &wrong_fingerprint, &salt_config));
273    }
274
275    #[test]
276    fn test_fingerprint_matches_missing_secret() {
277        let secrets = ResolvedSecrets::new();
278        let salt_config = SaltConfig::new(Some("my-salt".to_string()));
279
280        assert!(!secrets.fingerprint_matches("NONEXISTENT", "any-fingerprint", &salt_config));
281    }
282
283    #[test]
284    fn test_fingerprint_matches_no_salt_configured() {
285        let mut secrets = ResolvedSecrets::new();
286        secrets
287            .values
288            .insert("API_KEY".to_string(), "secret123".to_string());
289
290        let salt_config = SaltConfig::default();
291
292        // With no salt configured, no fingerprint should match
293        assert!(!secrets.fingerprint_matches("API_KEY", "any-fingerprint", &salt_config));
294    }
295
296    #[test]
297    fn test_compute_fingerprints_for_validation_both_salts() {
298        let mut secrets = ResolvedSecrets::new();
299        secrets
300            .values
301            .insert("DB_PASS".to_string(), "password".to_string());
302
303        let salt_config = SaltConfig::with_rotation(
304            Some("current-salt".to_string()),
305            Some("previous-salt".to_string()),
306        );
307
308        let (current_fp, previous_fp) =
309            secrets.compute_fingerprints_for_validation("DB_PASS", &salt_config);
310
311        assert!(current_fp.is_some());
312        assert!(previous_fp.is_some());
313        assert_ne!(current_fp, previous_fp);
314
315        // Verify fingerprints are correct
316        let expected_current = compute_secret_fingerprint("DB_PASS", "password", "current-salt");
317        let expected_previous = compute_secret_fingerprint("DB_PASS", "password", "previous-salt");
318        assert_eq!(current_fp.unwrap(), expected_current);
319        assert_eq!(previous_fp.unwrap(), expected_previous);
320    }
321
322    #[test]
323    fn test_compute_fingerprints_for_validation_only_current() {
324        let mut secrets = ResolvedSecrets::new();
325        secrets
326            .values
327            .insert("TOKEN".to_string(), "abc123".to_string());
328
329        let salt_config = SaltConfig::new(Some("only-current".to_string()));
330
331        let (current_fp, previous_fp) =
332            secrets.compute_fingerprints_for_validation("TOKEN", &salt_config);
333
334        assert!(current_fp.is_some());
335        assert!(previous_fp.is_none());
336    }
337
338    #[test]
339    fn test_compute_fingerprints_for_validation_only_previous() {
340        let mut secrets = ResolvedSecrets::new();
341        secrets
342            .values
343            .insert("TOKEN".to_string(), "abc123".to_string());
344
345        let salt_config = SaltConfig::with_rotation(None, Some("only-previous".to_string()));
346
347        let (current_fp, previous_fp) =
348            secrets.compute_fingerprints_for_validation("TOKEN", &salt_config);
349
350        assert!(current_fp.is_none());
351        assert!(previous_fp.is_some());
352    }
353
354    #[test]
355    fn test_compute_fingerprints_for_validation_missing_secret() {
356        let secrets = ResolvedSecrets::new();
357        let salt_config = SaltConfig::new(Some("salt".to_string()));
358
359        let (current_fp, previous_fp) =
360            secrets.compute_fingerprints_for_validation("MISSING", &salt_config);
361
362        assert!(current_fp.is_none());
363        assert!(previous_fp.is_none());
364    }
365
366    #[test]
367    fn test_compute_fingerprints_for_validation_no_salt() {
368        let mut secrets = ResolvedSecrets::new();
369        secrets
370            .values
371            .insert("KEY".to_string(), "value".to_string());
372
373        let salt_config = SaltConfig::default();
374
375        let (current_fp, previous_fp) =
376            secrets.compute_fingerprints_for_validation("KEY", &salt_config);
377
378        assert!(current_fp.is_none());
379        assert!(previous_fp.is_none());
380    }
381
382    #[test]
383    fn test_resolved_secrets_clone() {
384        let mut secrets = ResolvedSecrets::new();
385        secrets.values.insert("K1".to_string(), "V1".to_string());
386        secrets
387            .fingerprints
388            .insert("K1".to_string(), "FP1".to_string());
389
390        let cloned = secrets.clone();
391        assert_eq!(cloned.values.get("K1"), Some(&"V1".to_string()));
392        assert_eq!(cloned.fingerprints.get("K1"), Some(&"FP1".to_string()));
393    }
394
395    #[test]
396    fn test_resolved_secrets_debug() {
397        let secrets = ResolvedSecrets::new();
398        let debug = format!("{secrets:?}");
399        assert!(debug.contains("ResolvedSecrets"));
400    }
401
402    #[test]
403    fn test_multiple_secrets() {
404        let mut secrets = ResolvedSecrets::new();
405        secrets
406            .values
407            .insert("KEY1".to_string(), "value1".to_string());
408        secrets
409            .values
410            .insert("KEY2".to_string(), "value2".to_string());
411        secrets
412            .values
413            .insert("KEY3".to_string(), "value3".to_string());
414
415        assert_eq!(secrets.values.len(), 3);
416        assert!(!secrets.is_empty());
417        assert_eq!(secrets.get("KEY1"), Some("value1"));
418        assert_eq!(secrets.get("KEY2"), Some("value2"));
419        assert_eq!(secrets.get("KEY3"), Some("value3"));
420    }
421}