Skip to main content

cuenv_secrets/
types.rs

1//! Secure secret types with automatic memory zeroing
2//!
3//! This module provides types for handling secrets securely in memory:
4//! - [`SecureSecret`]: A wrapper around `secrecy::SecretString` that auto-zeros on drop
5//! - [`BatchSecrets`]: A collection of resolved secrets with per-batch lifetime
6
7use secrecy::{ExposeSecret, SecretString};
8use std::collections::HashMap;
9
10/// A resolved secret value with automatic memory zeroing on drop.
11///
12/// This type wraps `secrecy::SecretString` to ensure:
13/// - Secret values are zeroed from memory when dropped
14/// - Debug output shows `[REDACTED]` instead of the actual value
15/// - Explicit `.expose()` call required to access the value
16///
17/// # Example
18///
19/// ```ignore
20/// let secret = SecureSecret::new("my-password".to_string());
21/// // Use the secret
22/// let value = secret.expose();
23/// // When `secret` goes out of scope, memory is zeroed
24/// ```
25#[derive(Clone)]
26pub struct SecureSecret {
27    inner: SecretString,
28}
29
30impl SecureSecret {
31    /// Create a new secure secret from a string.
32    ///
33    /// The string value is moved into secure storage and will be
34    /// automatically zeroed when this `SecureSecret` is dropped.
35    #[must_use]
36    pub fn new(value: String) -> Self {
37        Self {
38            inner: SecretString::from(value),
39        }
40    }
41
42    /// Expose the secret value for use.
43    ///
44    /// # Safety Note
45    ///
46    /// The caller must ensure the exposed value is:
47    /// - Not logged or printed
48    /// - Not persisted to disk
49    /// - Used only for the immediate operation (e.g., setting an env var)
50    #[must_use]
51    pub fn expose(&self) -> &str {
52        self.inner.expose_secret()
53    }
54
55    /// Get the length of the secret value without exposing it.
56    #[must_use]
57    pub fn len(&self) -> usize {
58        self.inner.expose_secret().len()
59    }
60
61    /// Check if the secret value is empty.
62    #[must_use]
63    pub fn is_empty(&self) -> bool {
64        self.inner.expose_secret().is_empty()
65    }
66}
67
68impl std::fmt::Debug for SecureSecret {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.write_str("[REDACTED]")
71    }
72}
73
74impl std::fmt::Display for SecureSecret {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str("[REDACTED]")
77    }
78}
79
80/// Batch of resolved secrets with per-batch lifetime.
81///
82/// Secrets are automatically zeroed when this struct is dropped.
83/// This provides secure handling for secrets resolved ahead of task execution.
84///
85/// # Lifetime
86///
87/// This struct is designed for per-batch use:
88/// 1. Resolve all secrets needed for a batch of tasks
89/// 2. Use the secrets during task execution
90/// 3. Drop the `BatchSecrets` when the batch completes
91/// 4. Memory is automatically zeroed on drop
92///
93/// # Example
94///
95/// ```ignore
96/// let mut batch = BatchSecrets::new();
97/// batch.insert("API_KEY".to_string(), SecureSecret::new("secret".to_string()), None);
98///
99/// // Use during task execution
100/// if let Some(secret) = batch.get("API_KEY") {
101///     std::env::set_var("API_KEY", secret.expose());
102/// }
103///
104/// // When batch goes out of scope, all secrets are zeroed
105/// ```
106#[derive(Default)]
107pub struct BatchSecrets {
108    /// Secret name -> secure value
109    secrets: HashMap<String, SecureSecret>,
110    /// Secret name -> HMAC fingerprint (for cache keys)
111    fingerprints: HashMap<String, String>,
112}
113
114impl BatchSecrets {
115    /// Create an empty batch.
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Create a batch with pre-allocated capacity.
122    #[must_use]
123    pub fn with_capacity(capacity: usize) -> Self {
124        Self {
125            secrets: HashMap::with_capacity(capacity),
126            fingerprints: HashMap::with_capacity(capacity),
127        }
128    }
129
130    /// Insert a secret into the batch.
131    ///
132    /// # Arguments
133    ///
134    /// * `name` - The secret name/key
135    /// * `value` - The secure secret value
136    /// * `fingerprint` - Optional HMAC fingerprint for cache key inclusion
137    pub fn insert(&mut self, name: String, value: SecureSecret, fingerprint: Option<String>) {
138        if let Some(fp) = fingerprint {
139            self.fingerprints.insert(name.clone(), fp);
140        }
141        self.secrets.insert(name, value);
142    }
143
144    /// Get a secret by name.
145    #[must_use]
146    pub fn get(&self, name: &str) -> Option<&SecureSecret> {
147        self.secrets.get(name)
148    }
149
150    /// Check if the batch contains a secret.
151    #[must_use]
152    pub fn contains(&self, name: &str) -> bool {
153        self.secrets.contains_key(name)
154    }
155
156    /// Check if the batch is empty.
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.secrets.is_empty()
160    }
161
162    /// Get the number of secrets in the batch.
163    #[must_use]
164    pub fn len(&self) -> usize {
165        self.secrets.len()
166    }
167
168    /// Get the fingerprints map.
169    #[must_use]
170    pub const fn fingerprints(&self) -> &HashMap<String, String> {
171        &self.fingerprints
172    }
173
174    /// Get the fingerprint for a specific secret.
175    #[must_use]
176    pub fn fingerprint(&self, name: &str) -> Option<&str> {
177        self.fingerprints.get(name).map(String::as_str)
178    }
179
180    /// Iterate over secret names.
181    pub fn names(&self) -> impl Iterator<Item = &String> {
182        self.secrets.keys()
183    }
184
185    /// Convert to environment variable map for process injection.
186    ///
187    /// # Warning
188    ///
189    /// This exposes all secret values. Use carefully and ensure the
190    /// resulting map is not logged or persisted.
191    #[must_use]
192    pub fn into_env_map(self) -> HashMap<String, String> {
193        self.secrets
194            .into_iter()
195            .map(|(k, v)| (k, v.expose().to_string()))
196            .collect()
197    }
198
199    /// Convert to `ResolvedSecrets` for backward compatibility.
200    ///
201    /// This consumes the batch and converts it to the legacy format.
202    #[must_use]
203    pub fn into_resolved_secrets(self) -> crate::ResolvedSecrets {
204        let fingerprints = self.fingerprints;
205        let values = self
206            .secrets
207            .into_iter()
208            .map(|(k, v)| (k, v.expose().to_string()))
209            .collect();
210        crate::ResolvedSecrets {
211            values,
212            fingerprints,
213        }
214    }
215
216    /// Merge another batch into this one.
217    ///
218    /// Secrets from `other` will overwrite existing secrets with the same name.
219    pub fn merge(&mut self, other: Self) {
220        for (name, value) in other.secrets {
221            let fingerprint = other.fingerprints.get(&name).cloned();
222            self.insert(name, value, fingerprint);
223        }
224    }
225}
226
227impl std::fmt::Debug for BatchSecrets {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("BatchSecrets")
230            .field("count", &self.secrets.len())
231            .field("names", &self.secrets.keys().collect::<Vec<_>>())
232            .field("fingerprints", &self.fingerprints.len())
233            .finish()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn secure_secret_debug_is_redacted() {
243        let secret = SecureSecret::new("my-super-secret-password".to_string());
244        let debug_output = format!("{secret:?}");
245        assert_eq!(debug_output, "[REDACTED]");
246        assert!(!debug_output.contains("password"));
247    }
248
249    #[test]
250    fn secure_secret_display_is_redacted() {
251        let secret = SecureSecret::new("my-super-secret-password".to_string());
252        let display_output = format!("{secret}");
253        assert_eq!(display_output, "[REDACTED]");
254    }
255
256    #[test]
257    fn secure_secret_expose_returns_value() {
258        let secret = SecureSecret::new("test-value".to_string());
259        assert_eq!(secret.expose(), "test-value");
260    }
261
262    #[test]
263    fn secure_secret_len_works() {
264        let secret = SecureSecret::new("12345".to_string());
265        assert_eq!(secret.len(), 5);
266        assert!(!secret.is_empty());
267    }
268
269    #[test]
270    fn batch_secrets_insert_and_get() {
271        let mut batch = BatchSecrets::new();
272        batch.insert(
273            "API_KEY".to_string(),
274            SecureSecret::new("secret123".to_string()),
275            Some("fingerprint123".to_string()),
276        );
277
278        assert!(batch.contains("API_KEY"));
279        assert!(!batch.contains("OTHER"));
280        assert_eq!(batch.len(), 1);
281
282        let secret = batch.get("API_KEY").unwrap();
283        assert_eq!(secret.expose(), "secret123");
284        assert_eq!(batch.fingerprint("API_KEY"), Some("fingerprint123"));
285    }
286
287    #[test]
288    fn batch_secrets_debug_hides_values() {
289        let mut batch = BatchSecrets::new();
290        batch.insert(
291            "SECRET".to_string(),
292            SecureSecret::new("password".to_string()),
293            None,
294        );
295
296        let debug_output = format!("{batch:?}");
297        assert!(!debug_output.contains("password"));
298        assert!(debug_output.contains("SECRET"));
299        assert!(debug_output.contains("count"));
300    }
301
302    #[test]
303    fn batch_secrets_into_env_map() {
304        let mut batch = BatchSecrets::new();
305        batch.insert(
306            "KEY1".to_string(),
307            SecureSecret::new("value1".to_string()),
308            None,
309        );
310        batch.insert(
311            "KEY2".to_string(),
312            SecureSecret::new("value2".to_string()),
313            None,
314        );
315
316        let env_map = batch.into_env_map();
317        assert_eq!(env_map.get("KEY1"), Some(&"value1".to_string()));
318        assert_eq!(env_map.get("KEY2"), Some(&"value2".to_string()));
319    }
320
321    #[test]
322    fn batch_secrets_merge() {
323        let mut batch1 = BatchSecrets::new();
324        batch1.insert(
325            "KEY1".to_string(),
326            SecureSecret::new("value1".to_string()),
327            None,
328        );
329
330        let mut batch2 = BatchSecrets::new();
331        batch2.insert(
332            "KEY2".to_string(),
333            SecureSecret::new("value2".to_string()),
334            Some("fp2".to_string()),
335        );
336
337        batch1.merge(batch2);
338
339        assert_eq!(batch1.len(), 2);
340        assert!(batch1.contains("KEY1"));
341        assert!(batch1.contains("KEY2"));
342        assert_eq!(batch1.fingerprint("KEY2"), Some("fp2"));
343    }
344}