cuenv_ci/executor/
secrets.rs

1//! Secret Resolution for CI Execution
2//!
3//! Provides trait-based secret resolution for CI tasks, enabling:
4//! - Environment variable resolution (default)
5//! - Mock resolvers for testing
6//! - Custom provider integration (Vault, 1Password, etc.)
7
8use crate::ir::SecretConfig;
9use std::collections::HashMap;
10
11// Re-export core types from cuenv-secrets
12pub use cuenv_secrets::{ResolvedSecrets, SaltConfig, SecretError, compute_secret_fingerprint};
13
14/// Trait for resolving secrets from various sources
15///
16/// Implement this trait to support custom secret providers like Vault,
17/// AWS Secrets Manager, 1Password, etc.
18pub trait SecretResolver: Send + Sync {
19    /// Resolve a single secret by name and configuration
20    ///
21    /// # Arguments
22    /// * `name` - The logical name of the secret
23    /// * `config` - Configuration specifying the source
24    ///
25    /// # Errors
26    ///
27    /// Returns `SecretError` if the secret cannot be resolved.
28    fn resolve(&self, name: &str, config: &SecretConfig) -> Result<String, SecretError>;
29}
30
31/// Default resolver that reads secrets from environment variables
32#[derive(Debug, Clone, Default)]
33pub struct EnvSecretResolver;
34
35impl SecretResolver for EnvSecretResolver {
36    fn resolve(&self, name: &str, config: &SecretConfig) -> Result<String, SecretError> {
37        std::env::var(&config.source).map_err(|_| SecretError::NotFound {
38            name: name.to_string(),
39            secret_source: config.source.clone(),
40        })
41    }
42}
43
44/// Mock resolver for testing that returns predefined values
45#[derive(Debug, Clone, Default)]
46pub struct MockSecretResolver {
47    secrets: HashMap<String, String>,
48}
49
50impl MockSecretResolver {
51    /// Create a new mock resolver
52    #[must_use]
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Add a secret to the mock resolver
58    #[must_use]
59    pub fn with_secret(mut self, source: impl Into<String>, value: impl Into<String>) -> Self {
60        self.secrets.insert(source.into(), value.into());
61        self
62    }
63}
64
65impl SecretResolver for MockSecretResolver {
66    fn resolve(&self, name: &str, config: &SecretConfig) -> Result<String, SecretError> {
67        self.secrets
68            .get(&config.source)
69            .cloned()
70            .ok_or_else(|| SecretError::NotFound {
71                name: name.to_string(),
72                secret_source: config.source.clone(),
73            })
74    }
75}
76
77/// CI-specific resolved secrets with convenience methods for IR types
78#[derive(Debug, Clone, Default)]
79pub struct CIResolvedSecrets {
80    inner: ResolvedSecrets,
81}
82
83impl CIResolvedSecrets {
84    /// Resolve secrets from environment variables using CI IR types
85    ///
86    /// # Arguments
87    /// * `secrets` - Map of secret names to their CI configuration
88    /// * `salt` - Optional system salt for HMAC computation
89    ///
90    /// # Errors
91    /// Returns error if a required secret is not found or if salt is missing
92    /// when secrets have `cache_key: true`
93    pub fn from_env(
94        secrets: &HashMap<String, SecretConfig>,
95        salt: Option<&str>,
96    ) -> Result<Self, SecretError> {
97        let salt_config = SaltConfig::new(salt.map(String::from));
98        Self::from_env_with_salt_config(secrets, &salt_config)
99    }
100
101    /// Resolve secrets with salt rotation support using CI IR types
102    ///
103    /// # Errors
104    ///
105    /// Returns `SecretError` if any secret cannot be resolved.
106    pub fn from_env_with_salt_config(
107        secrets: &HashMap<String, SecretConfig>,
108        salt_config: &SaltConfig,
109    ) -> Result<Self, SecretError> {
110        Self::resolve_with_resolver(&EnvSecretResolver, secrets, salt_config)
111    }
112
113    /// Resolve secrets using a custom resolver
114    ///
115    /// # Arguments
116    /// * `resolver` - The secret resolver implementation
117    /// * `secrets` - Map of secret names to their CI configuration
118    /// * `salt_config` - Salt configuration for fingerprinting
119    ///
120    /// # Errors
121    /// Returns error if resolution fails or salt is missing for cache keys
122    pub fn resolve_with_resolver(
123        resolver: &impl SecretResolver,
124        secrets: &HashMap<String, SecretConfig>,
125        salt_config: &SaltConfig,
126    ) -> Result<Self, SecretError> {
127        let mut values = HashMap::new();
128        let mut fingerprints = HashMap::new();
129
130        // Check if any secret requires cache key and salt is missing
131        let needs_salt = secrets.values().any(|c| c.cache_key);
132        if needs_salt && !salt_config.has_salt() {
133            return Err(SecretError::MissingSalt);
134        }
135
136        for (name, config) in secrets {
137            let value = resolver.resolve(name, config)?;
138
139            // Compute fingerprint if secret affects cache
140            if config.cache_key {
141                // Warn if secret is too short (but don't fail)
142                if value.len() < 4 {
143                    tracing::warn!(
144                        secret = %name,
145                        len = value.len(),
146                        "Secret is too short for safe cache key inclusion"
147                    );
148                }
149
150                // Use write_salt for computing fingerprints (current salt preferred)
151                let fingerprint = compute_secret_fingerprint(
152                    name,
153                    &value,
154                    salt_config.write_salt().unwrap_or(""),
155                );
156                fingerprints.insert(name.clone(), fingerprint);
157            }
158
159            values.insert(name.clone(), value);
160        }
161
162        Ok(Self {
163            inner: ResolvedSecrets {
164                values,
165                fingerprints,
166            },
167        })
168    }
169
170    /// Check if any secrets were resolved
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.inner.is_empty()
174    }
175
176    /// Get a resolved secret value by name
177    #[must_use]
178    pub fn get(&self, name: &str) -> Option<&str> {
179        self.inner.get(name)
180    }
181
182    /// Get the inner values map
183    #[must_use]
184    pub fn values(&self) -> &HashMap<String, String> {
185        &self.inner.values
186    }
187
188    /// Get the inner fingerprints map
189    #[must_use]
190    pub fn fingerprints(&self) -> &HashMap<String, String> {
191        &self.inner.fingerprints
192    }
193
194    /// Check if a cached fingerprint matches with salt rotation support
195    #[must_use]
196    pub fn fingerprint_matches(
197        &self,
198        name: &str,
199        cached_fingerprint: &str,
200        salt_config: &SaltConfig,
201    ) -> bool {
202        self.inner
203            .fingerprint_matches(name, cached_fingerprint, salt_config)
204    }
205
206    /// Compute fingerprints using both current and previous salts
207    #[must_use]
208    pub fn compute_fingerprints_for_validation(
209        &self,
210        name: &str,
211        salt_config: &SaltConfig,
212    ) -> (Option<String>, Option<String>) {
213        self.inner
214            .compute_fingerprints_for_validation(name, salt_config)
215    }
216}
217
218impl std::ops::Deref for CIResolvedSecrets {
219    type Target = ResolvedSecrets;
220
221    fn deref(&self) -> &Self::Target {
222        &self.inner
223    }
224}
225
226/// Resolve secrets for all tasks in an IR
227///
228/// Returns a map of `task_id` -> `CIResolvedSecrets`
229///
230/// # Errors
231///
232/// Returns `SecretError` if any secret cannot be resolved.
233pub fn resolve_all_task_secrets(
234    tasks: &[crate::ir::Task],
235    salt: Option<&str>,
236) -> Result<HashMap<String, CIResolvedSecrets>, SecretError> {
237    let mut result = HashMap::new();
238
239    for task in tasks {
240        if !task.secrets.is_empty() {
241            let resolved = CIResolvedSecrets::from_env(&task.secrets, salt)?;
242            result.insert(task.id.clone(), resolved);
243        }
244    }
245
246    Ok(result)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn make_secret_config(source: &str, cache_key: bool) -> SecretConfig {
254        SecretConfig {
255            source: source.to_string(),
256            cache_key,
257        }
258    }
259
260    #[test]
261    fn test_fingerprint_deterministic() {
262        let fp1 = compute_secret_fingerprint("API_KEY", "secret123", "salt");
263        let fp2 = compute_secret_fingerprint("API_KEY", "secret123", "salt");
264        assert_eq!(fp1, fp2);
265    }
266
267    #[test]
268    fn test_fingerprint_changes_with_value() {
269        let fp1 = compute_secret_fingerprint("API_KEY", "secret123", "salt");
270        let fp2 = compute_secret_fingerprint("API_KEY", "secret456", "salt");
271        assert_ne!(fp1, fp2);
272    }
273
274    #[test]
275    fn test_fingerprint_changes_with_salt() {
276        let fp1 = compute_secret_fingerprint("API_KEY", "secret123", "salt1");
277        let fp2 = compute_secret_fingerprint("API_KEY", "secret123", "salt2");
278        assert_ne!(fp1, fp2);
279    }
280
281    #[test]
282    fn test_fingerprint_changes_with_name() {
283        let fp1 = compute_secret_fingerprint("API_KEY", "secret123", "salt");
284        let fp2 = compute_secret_fingerprint("DB_PASSWORD", "secret123", "salt");
285        assert_ne!(fp1, fp2);
286    }
287
288    #[test]
289    fn test_resolve_from_env() {
290        temp_env::with_vars(
291            [
292                ("TEST_SECRET_1", Some("value1")),
293                ("TEST_SECRET_2", Some("value2")),
294            ],
295            || {
296                let secrets = HashMap::from([
297                    (
298                        "secret1".to_string(),
299                        make_secret_config("TEST_SECRET_1", true),
300                    ),
301                    (
302                        "secret2".to_string(),
303                        make_secret_config("TEST_SECRET_2", false),
304                    ),
305                ]);
306
307                let resolved = CIResolvedSecrets::from_env(&secrets, Some("test-salt")).unwrap();
308
309                assert_eq!(
310                    resolved.values().get("secret1"),
311                    Some(&"value1".to_string())
312                );
313                assert_eq!(
314                    resolved.values().get("secret2"),
315                    Some(&"value2".to_string())
316                );
317                assert!(resolved.fingerprints().contains_key("secret1"));
318                assert!(!resolved.fingerprints().contains_key("secret2")); // cache_key: false
319            },
320        );
321    }
322
323    #[test]
324    fn test_missing_secret() {
325        let secrets = HashMap::from([(
326            "missing".to_string(),
327            make_secret_config("NONEXISTENT_VAR", false),
328        )]);
329
330        let result = CIResolvedSecrets::from_env(&secrets, None);
331        assert!(matches!(result, Err(SecretError::NotFound { .. })));
332    }
333
334    #[test]
335    fn test_missing_salt_with_cache_key() {
336        temp_env::with_var("TEST_SALT_CHECK", Some("value"), || {
337            let secrets = HashMap::from([(
338                "secret".to_string(),
339                make_secret_config("TEST_SALT_CHECK", true),
340            )]);
341
342            let result = CIResolvedSecrets::from_env(&secrets, None);
343            assert!(matches!(result, Err(SecretError::MissingSalt)));
344        });
345    }
346
347    #[test]
348    fn test_salt_config_new() {
349        let config = SaltConfig::new(Some("current".to_string()));
350        assert_eq!(config.current, Some("current".to_string()));
351        assert_eq!(config.previous, None);
352        assert!(config.has_salt());
353        assert_eq!(config.write_salt(), Some("current"));
354    }
355
356    #[test]
357    fn test_salt_config_with_rotation() {
358        let config =
359            SaltConfig::with_rotation(Some("new-salt".to_string()), Some("old-salt".to_string()));
360        assert_eq!(config.current, Some("new-salt".to_string()));
361        assert_eq!(config.previous, Some("old-salt".to_string()));
362        assert!(config.has_salt());
363        assert_eq!(config.write_salt(), Some("new-salt"));
364    }
365
366    #[test]
367    fn test_fingerprint_matches_current_salt() {
368        temp_env::with_var("TEST_FP_MATCH_1", Some("secret_value"), || {
369            let secrets = HashMap::from([(
370                "api_key".to_string(),
371                make_secret_config("TEST_FP_MATCH_1", true),
372            )]);
373
374            let salt_config = SaltConfig::with_rotation(
375                Some("current-salt".to_string()),
376                Some("old-salt".to_string()),
377            );
378
379            let resolved =
380                CIResolvedSecrets::from_env_with_salt_config(&secrets, &salt_config).unwrap();
381
382            let cached_fp = compute_secret_fingerprint("api_key", "secret_value", "current-salt");
383            assert!(resolved.fingerprint_matches("api_key", &cached_fp, &salt_config));
384        });
385    }
386
387    #[test]
388    fn test_fingerprint_matches_previous_salt() {
389        temp_env::with_var("TEST_FP_MATCH_2", Some("secret_value"), || {
390            let secrets = HashMap::from([(
391                "api_key".to_string(),
392                make_secret_config("TEST_FP_MATCH_2", true),
393            )]);
394
395            let salt_config = SaltConfig::with_rotation(
396                Some("new-salt".to_string()),
397                Some("old-salt".to_string()),
398            );
399
400            let resolved =
401                CIResolvedSecrets::from_env_with_salt_config(&secrets, &salt_config).unwrap();
402
403            let cached_fp = compute_secret_fingerprint("api_key", "secret_value", "old-salt");
404            assert!(resolved.fingerprint_matches("api_key", &cached_fp, &salt_config));
405        });
406    }
407
408    #[test]
409    fn test_mock_resolver() {
410        let mock_resolver = MockSecretResolver::new()
411            .with_secret("API_KEY_SOURCE", "mock_api_key_value")
412            .with_secret("DB_PASSWORD_SOURCE", "mock_db_password");
413
414        let secrets = HashMap::from([
415            (
416                "api_key".to_string(),
417                make_secret_config("API_KEY_SOURCE", true),
418            ),
419            (
420                "db_password".to_string(),
421                make_secret_config("DB_PASSWORD_SOURCE", false),
422            ),
423        ]);
424
425        let salt_config = SaltConfig::new(Some("test-salt".to_string()));
426        let result =
427            CIResolvedSecrets::resolve_with_resolver(&mock_resolver, &secrets, &salt_config)
428                .unwrap();
429
430        assert_eq!(
431            result.values().get("api_key"),
432            Some(&"mock_api_key_value".to_string())
433        );
434        assert_eq!(
435            result.values().get("db_password"),
436            Some(&"mock_db_password".to_string())
437        );
438        assert!(result.fingerprints().contains_key("api_key"));
439        assert!(!result.fingerprints().contains_key("db_password"));
440    }
441
442    #[test]
443    fn test_mock_resolver_missing_secret() {
444        let resolver = MockSecretResolver::new();
445
446        let secrets = HashMap::from([(
447            "missing".to_string(),
448            make_secret_config("NONEXISTENT_SOURCE", false),
449        )]);
450
451        let salt_config = SaltConfig::new(None);
452        let result = CIResolvedSecrets::resolve_with_resolver(&resolver, &secrets, &salt_config);
453        assert!(matches!(result, Err(SecretError::NotFound { .. })));
454    }
455}