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