Skip to main content

cuenv_secrets/
lib.rs

1//! Secret Resolution for cuenv
2//!
3//! Provides a unified interface for resolving secrets from various providers
4//! (environment variables, command execution, 1Password, Vault, etc.) with
5//! support for cache key fingerprinting and salt rotation.
6//!
7//! # Batch Resolution
8//!
9//! For resolving multiple secrets efficiently, use the batch resolution API:
10//!
11//! ```ignore
12//! use cuenv_secrets::{BatchSecrets, SecretResolver, SecretSpec};
13//!
14//! // Resolve multiple secrets concurrently
15//! let secrets = resolver.resolve_batch(&specs).await?;
16//!
17//! // Use secrets during task execution
18//! for name in secrets.names() {
19//!     if let Some(secret) = secrets.get(name) {
20//!         std::env::set_var(name, secret.expose());
21//!     }
22//! }
23//! // Secrets are zeroed when `secrets` goes out of scope
24//! ```
25
26mod batch;
27mod fingerprint;
28mod registry;
29mod resolved;
30pub mod resolvers;
31mod salt;
32mod types;
33
34pub use batch::{BatchConfig, BatchResolver, resolve_batch};
35pub use fingerprint::compute_secret_fingerprint;
36pub use registry::SecretRegistry;
37pub use resolved::ResolvedSecrets;
38pub use salt::SaltConfig;
39pub use types::{BatchSecrets, SecureSecret};
40
41// Re-export built-in resolvers (no external dependencies)
42pub use resolvers::{EnvSecretResolver, ExecSecretResolver};
43
44// Provider implementations are in separate crates:
45// - cuenv-1password: OnePasswordResolver, OnePasswordConfig
46
47use async_trait::async_trait;
48use serde::{Deserialize, Serialize};
49use std::collections::HashMap;
50use thiserror::Error;
51
52/// Error types for secret resolution
53#[derive(Debug, Error)]
54pub enum SecretError {
55    /// Secret not found
56    #[error("Secret '{name}' not found from source '{secret_source}'")]
57    NotFound {
58        /// Secret name
59        name: String,
60        /// Source that was searched (e.g., env var name)
61        secret_source: String,
62    },
63
64    /// Secret is too short for safe fingerprinting (< 4 chars)
65    #[error("Secret '{name}' is too short ({len} chars, minimum 4) for cache key inclusion")]
66    TooShort {
67        /// Secret name
68        name: String,
69        /// Actual length of the secret value
70        len: usize,
71    },
72
73    /// Missing salt when secrets require fingerprinting
74    #[error("CUENV_SECRET_SALT required when secrets have cache_key: true")]
75    MissingSalt,
76
77    /// Resolver execution failed
78    #[error("Failed to resolve secret '{name}': {message}")]
79    ResolutionFailed {
80        /// Secret name
81        name: String,
82        /// Error message from the resolver
83        message: String,
84    },
85
86    /// Unsupported resolver type
87    #[error("Unsupported secret resolver: {resolver}")]
88    UnsupportedResolver {
89        /// The resolver type that was requested
90        resolver: String,
91    },
92}
93
94/// Configuration for a secret to resolve
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct SecretSpec {
97    /// Source reference (env var name, 1Password reference, etc.)
98    pub source: String,
99
100    /// Include secret in cache key via salted HMAC
101    #[serde(default)]
102    pub cache_key: bool,
103}
104
105impl SecretSpec {
106    /// Create a new secret spec
107    #[must_use]
108    pub fn new(source: impl Into<String>) -> Self {
109        Self {
110            source: source.into(),
111            cache_key: false,
112        }
113    }
114
115    /// Create a secret spec that affects cache keys
116    #[must_use]
117    pub fn with_cache_key(source: impl Into<String>) -> Self {
118        Self {
119            source: source.into(),
120            cache_key: true,
121        }
122    }
123}
124
125/// Trait for resolving secrets from various providers.
126///
127/// Implementors must provide:
128/// - [`resolve`](SecretResolver::resolve) - Single secret resolution
129/// - [`provider_name`](SecretResolver::provider_name) - Provider identifier for grouping
130///
131/// The trait provides default implementations for batch operations that can be
132/// overridden for providers with native batch APIs (e.g., AWS `BatchGetSecretValue`).
133#[async_trait]
134pub trait SecretResolver: Send + Sync {
135    /// Resolve a single secret by name and spec.
136    ///
137    /// This is the primary method that must be implemented by all resolvers.
138    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError>;
139
140    /// Get the provider name for this resolver.
141    ///
142    /// Used for grouping secrets by provider in batch resolution.
143    /// Examples: `"env"`, `"aws"`, `"vault"`, `"onepassword"`
144    fn provider_name(&self) -> &'static str;
145
146    /// Resolve a single secret returning a secure value.
147    ///
148    /// The returned [`SecureSecret`] will automatically zero its memory on drop.
149    async fn resolve_secure(
150        &self,
151        name: &str,
152        spec: &SecretSpec,
153    ) -> Result<SecureSecret, SecretError> {
154        let value = self.resolve(name, spec).await?;
155        Ok(SecureSecret::new(value))
156    }
157
158    /// Resolve multiple secrets in batch with concurrent execution.
159    ///
160    /// Override this method to implement provider-specific batch APIs
161    /// (e.g., AWS `BatchGetSecretValue`, 1Password `Secrets.ResolveAll`).
162    ///
163    /// The default implementation resolves secrets concurrently using
164    /// `futures::try_join_all`, which is optimal for providers without
165    /// native batch APIs.
166    ///
167    /// # Returns
168    ///
169    /// A map of secret names to [`SecureSecret`] values that will be
170    /// automatically zeroed on drop.
171    async fn resolve_batch(
172        &self,
173        secrets: &HashMap<String, SecretSpec>,
174    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
175        use futures::future::try_join_all;
176
177        let futures: Vec<_> = secrets
178            .iter()
179            .map(|(name, spec)| {
180                let name = name.clone();
181                let spec = spec.clone();
182                async move {
183                    let value = self.resolve_secure(&name, &spec).await?;
184                    Ok::<_, SecretError>((name, value))
185                }
186            })
187            .collect();
188
189        let results = try_join_all(futures).await?;
190        Ok(results.into_iter().collect())
191    }
192
193    /// Check if this resolver supports native batch resolution.
194    ///
195    /// Returns `true` if the provider has a native batch API that is more
196    /// efficient than concurrent single calls.
197    fn supports_native_batch(&self) -> bool {
198        false
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_secret_error_not_found() {
208        let err = SecretError::NotFound {
209            name: "API_KEY".to_string(),
210            secret_source: "env:API_KEY".to_string(),
211        };
212        let msg = err.to_string();
213        assert!(msg.contains("API_KEY"));
214        assert!(msg.contains("env:API_KEY"));
215    }
216
217    #[test]
218    fn test_secret_error_too_short() {
219        let err = SecretError::TooShort {
220            name: "SHORT_SECRET".to_string(),
221            len: 2,
222        };
223        let msg = err.to_string();
224        assert!(msg.contains("SHORT_SECRET"));
225        assert!(msg.contains("2 chars"));
226        assert!(msg.contains("minimum 4"));
227    }
228
229    #[test]
230    fn test_secret_error_missing_salt() {
231        let err = SecretError::MissingSalt;
232        let msg = err.to_string();
233        assert!(msg.contains("CUENV_SECRET_SALT"));
234        assert!(msg.contains("cache_key: true"));
235    }
236
237    #[test]
238    fn test_secret_error_resolution_failed() {
239        let err = SecretError::ResolutionFailed {
240            name: "DATABASE_URL".to_string(),
241            message: "connection timeout".to_string(),
242        };
243        let msg = err.to_string();
244        assert!(msg.contains("DATABASE_URL"));
245        assert!(msg.contains("connection timeout"));
246    }
247
248    #[test]
249    fn test_secret_error_unsupported_resolver() {
250        let err = SecretError::UnsupportedResolver {
251            resolver: "unknown".to_string(),
252        };
253        let msg = err.to_string();
254        assert!(msg.contains("unknown"));
255    }
256
257    #[test]
258    fn test_secret_error_debug() {
259        let err = SecretError::MissingSalt;
260        let debug = format!("{err:?}");
261        assert!(debug.contains("MissingSalt"));
262    }
263
264    #[test]
265    fn test_secret_spec_new() {
266        let spec = SecretSpec::new("env:API_KEY");
267        assert_eq!(spec.source, "env:API_KEY");
268        assert!(!spec.cache_key);
269    }
270
271    #[test]
272    fn test_secret_spec_with_cache_key() {
273        let spec = SecretSpec::with_cache_key("env:CACHE_AFFECTING_SECRET");
274        assert_eq!(spec.source, "env:CACHE_AFFECTING_SECRET");
275        assert!(spec.cache_key);
276    }
277
278    #[test]
279    fn test_secret_spec_new_with_string() {
280        let spec = SecretSpec::new(String::from("vault://path/to/secret"));
281        assert_eq!(spec.source, "vault://path/to/secret");
282    }
283
284    #[test]
285    fn test_secret_spec_equality() {
286        let spec1 = SecretSpec::new("source1");
287        let spec2 = SecretSpec::new("source1");
288        let spec3 = SecretSpec::new("source2");
289        let spec4 = SecretSpec::with_cache_key("source1");
290
291        assert_eq!(spec1, spec2);
292        assert_ne!(spec1, spec3);
293        assert_ne!(spec1, spec4); // Different cache_key
294    }
295
296    #[test]
297    fn test_secret_spec_clone() {
298        let spec = SecretSpec::with_cache_key("important");
299        let cloned = spec.clone();
300        assert_eq!(spec, cloned);
301    }
302
303    #[test]
304    fn test_secret_spec_debug() {
305        let spec = SecretSpec::new("test-source");
306        let debug = format!("{spec:?}");
307        assert!(debug.contains("SecretSpec"));
308        assert!(debug.contains("test-source"));
309    }
310
311    #[test]
312    fn test_secret_spec_serialization() {
313        let spec = SecretSpec::with_cache_key("op://vault/item/field");
314        let json = serde_json::to_string(&spec).unwrap();
315        assert!(json.contains("op://vault/item/field"));
316        assert!(json.contains("cache_key"));
317
318        let parsed: SecretSpec = serde_json::from_str(&json).unwrap();
319        assert_eq!(spec, parsed);
320    }
321
322    #[test]
323    fn test_secret_spec_deserialization_default_cache_key() {
324        let json = r#"{"source": "test"}"#;
325        let spec: SecretSpec = serde_json::from_str(json).unwrap();
326        assert_eq!(spec.source, "test");
327        assert!(!spec.cache_key); // Default is false
328    }
329}