1use crate::ir::SecretConfig;
9use std::collections::HashMap;
10
11pub use cuenv_secrets::{ResolvedSecrets, SaltConfig, SecretError, compute_secret_fingerprint};
13
14pub trait SecretResolver: Send + Sync {
19 fn resolve(&self, name: &str, config: &SecretConfig) -> Result<String, SecretError>;
29}
30
31#[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#[derive(Debug, Clone, Default)]
46pub struct MockSecretResolver {
47 secrets: HashMap<String, String>,
48}
49
50impl MockSecretResolver {
51 #[must_use]
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 #[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#[derive(Debug, Clone, Default)]
79pub struct CIResolvedSecrets {
80 inner: ResolvedSecrets,
81}
82
83impl CIResolvedSecrets {
84 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 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 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 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 if config.cache_key {
141 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 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 #[must_use]
172 pub fn is_empty(&self) -> bool {
173 self.inner.is_empty()
174 }
175
176 #[must_use]
178 pub fn get(&self, name: &str) -> Option<&str> {
179 self.inner.get(name)
180 }
181
182 #[must_use]
184 pub fn values(&self) -> &HashMap<String, String> {
185 &self.inner.values
186 }
187
188 #[must_use]
190 pub fn fingerprints(&self) -> &HashMap<String, String> {
191 &self.inner.fingerprints
192 }
193
194 #[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 #[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
226pub 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")); },
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}