1use crate::{
4 BatchSecrets, SaltConfig, SecretError, SecretResolver, SecretSpec, compute_secret_fingerprint,
5};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Default)]
10pub struct ResolvedSecrets {
11 pub values: HashMap<String, String>,
13 pub fingerprints: HashMap<String, String>,
15}
16
17impl ResolvedSecrets {
18 #[must_use]
20 pub fn new() -> Self {
21 Self::default()
22 }
23
24 pub async fn resolve<R: SecretResolver>(
35 resolver: &R,
36 secrets: &HashMap<String, SecretSpec>,
37 salt_config: &SaltConfig,
38 ) -> Result<Self, SecretError> {
39 let mut values = HashMap::new();
40 let mut fingerprints = HashMap::new();
41
42 let needs_salt = secrets.values().any(|c| c.cache_key);
44 if needs_salt && !salt_config.has_salt() {
45 return Err(SecretError::MissingSalt);
46 }
47
48 for (name, spec) in secrets {
49 let value = resolver.resolve(name, spec).await?;
50
51 if spec.cache_key {
53 if value.len() < 4 {
55 tracing::warn!(
56 secret = %name,
57 len = value.len(),
58 "Secret is too short for safe cache key inclusion"
59 );
60 }
61
62 let fingerprint = compute_secret_fingerprint(
64 name,
65 &value,
66 salt_config.write_salt().unwrap_or(""),
67 );
68 fingerprints.insert(name.clone(), fingerprint);
69 }
70
71 values.insert(name.clone(), value);
72 }
73
74 Ok(Self {
75 values,
76 fingerprints,
77 })
78 }
79
80 #[must_use]
85 pub fn from_batch(batch: BatchSecrets) -> Self {
86 batch.into_resolved_secrets()
87 }
88
89 pub async fn resolve_batch<R: SecretResolver>(
104 resolver: &R,
105 secrets: &HashMap<String, SecretSpec>,
106 salt_config: &SaltConfig,
107 ) -> Result<Self, SecretError> {
108 let batch = crate::batch::resolve_batch(resolver, secrets, salt_config).await?;
109 Ok(Self::from_batch(batch))
110 }
111
112 #[must_use]
114 pub fn is_empty(&self) -> bool {
115 self.values.is_empty()
116 }
117
118 #[must_use]
120 pub fn get(&self, name: &str) -> Option<&str> {
121 self.values.get(name).map(String::as_str)
122 }
123
124 #[must_use]
138 pub fn fingerprint_matches(
139 &self,
140 name: &str,
141 cached_fingerprint: &str,
142 salt_config: &SaltConfig,
143 ) -> bool {
144 let Some(value) = self.values.get(name) else {
145 return false;
146 };
147
148 if let Some(current) = &salt_config.current {
150 let current_fp = compute_secret_fingerprint(name, value, current);
151 if current_fp == cached_fingerprint {
152 return true;
153 }
154 }
155
156 if let Some(previous) = &salt_config.previous {
158 let previous_fp = compute_secret_fingerprint(name, value, previous);
159 if previous_fp == cached_fingerprint {
160 tracing::debug!(
161 secret = %name,
162 "Cache hit using previous salt - rotation in progress"
163 );
164 return true;
165 }
166 }
167
168 false
169 }
170
171 #[must_use]
176 pub fn compute_fingerprints_for_validation(
177 &self,
178 name: &str,
179 salt_config: &SaltConfig,
180 ) -> (Option<String>, Option<String>) {
181 let Some(value) = self.values.get(name) else {
182 return (None, None);
183 };
184
185 let current_fp = salt_config
186 .current
187 .as_ref()
188 .map(|salt| compute_secret_fingerprint(name, value, salt));
189
190 let previous_fp = salt_config
191 .previous
192 .as_ref()
193 .map(|salt| compute_secret_fingerprint(name, value, salt));
194
195 (current_fp, previous_fp)
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn test_resolved_secrets_new_is_empty() {
205 let secrets = ResolvedSecrets::new();
206 assert!(secrets.is_empty());
207 assert!(secrets.values.is_empty());
208 assert!(secrets.fingerprints.is_empty());
209 }
210
211 #[test]
212 fn test_resolved_secrets_default_is_empty() {
213 let secrets = ResolvedSecrets::default();
214 assert!(secrets.is_empty());
215 }
216
217 #[test]
218 fn test_resolved_secrets_get_existing() {
219 let mut secrets = ResolvedSecrets::new();
220 secrets
221 .values
222 .insert("API_KEY".to_string(), "secret123".to_string());
223
224 assert_eq!(secrets.get("API_KEY"), Some("secret123"));
225 assert!(!secrets.is_empty());
226 }
227
228 #[test]
229 fn test_resolved_secrets_get_missing() {
230 let secrets = ResolvedSecrets::new();
231 assert_eq!(secrets.get("NONEXISTENT"), None);
232 }
233
234 #[test]
235 fn test_fingerprint_matches_with_current_salt() {
236 let mut secrets = ResolvedSecrets::new();
237 secrets
238 .values
239 .insert("API_KEY".to_string(), "secret123".to_string());
240
241 let salt_config = SaltConfig::new(Some("my-salt".to_string()));
242 let fingerprint = compute_secret_fingerprint("API_KEY", "secret123", "my-salt");
243
244 assert!(secrets.fingerprint_matches("API_KEY", &fingerprint, &salt_config));
245 }
246
247 #[test]
248 fn test_fingerprint_matches_with_previous_salt() {
249 let mut secrets = ResolvedSecrets::new();
250 secrets
251 .values
252 .insert("API_KEY".to_string(), "secret123".to_string());
253
254 let salt_config =
256 SaltConfig::with_rotation(Some("new-salt".to_string()), Some("old-salt".to_string()));
257 let old_fingerprint = compute_secret_fingerprint("API_KEY", "secret123", "old-salt");
258
259 assert!(secrets.fingerprint_matches("API_KEY", &old_fingerprint, &salt_config));
260 }
261
262 #[test]
263 fn test_fingerprint_matches_no_match() {
264 let mut secrets = ResolvedSecrets::new();
265 secrets
266 .values
267 .insert("API_KEY".to_string(), "secret123".to_string());
268
269 let salt_config = SaltConfig::new(Some("my-salt".to_string()));
270 let wrong_fingerprint = compute_secret_fingerprint("API_KEY", "wrong-secret", "my-salt");
271
272 assert!(!secrets.fingerprint_matches("API_KEY", &wrong_fingerprint, &salt_config));
273 }
274
275 #[test]
276 fn test_fingerprint_matches_missing_secret() {
277 let secrets = ResolvedSecrets::new();
278 let salt_config = SaltConfig::new(Some("my-salt".to_string()));
279
280 assert!(!secrets.fingerprint_matches("NONEXISTENT", "any-fingerprint", &salt_config));
281 }
282
283 #[test]
284 fn test_fingerprint_matches_no_salt_configured() {
285 let mut secrets = ResolvedSecrets::new();
286 secrets
287 .values
288 .insert("API_KEY".to_string(), "secret123".to_string());
289
290 let salt_config = SaltConfig::default();
291
292 assert!(!secrets.fingerprint_matches("API_KEY", "any-fingerprint", &salt_config));
294 }
295
296 #[test]
297 fn test_compute_fingerprints_for_validation_both_salts() {
298 let mut secrets = ResolvedSecrets::new();
299 secrets
300 .values
301 .insert("DB_PASS".to_string(), "password".to_string());
302
303 let salt_config = SaltConfig::with_rotation(
304 Some("current-salt".to_string()),
305 Some("previous-salt".to_string()),
306 );
307
308 let (current_fp, previous_fp) =
309 secrets.compute_fingerprints_for_validation("DB_PASS", &salt_config);
310
311 assert!(current_fp.is_some());
312 assert!(previous_fp.is_some());
313 assert_ne!(current_fp, previous_fp);
314
315 let expected_current = compute_secret_fingerprint("DB_PASS", "password", "current-salt");
317 let expected_previous = compute_secret_fingerprint("DB_PASS", "password", "previous-salt");
318 assert_eq!(current_fp.unwrap(), expected_current);
319 assert_eq!(previous_fp.unwrap(), expected_previous);
320 }
321
322 #[test]
323 fn test_compute_fingerprints_for_validation_only_current() {
324 let mut secrets = ResolvedSecrets::new();
325 secrets
326 .values
327 .insert("TOKEN".to_string(), "abc123".to_string());
328
329 let salt_config = SaltConfig::new(Some("only-current".to_string()));
330
331 let (current_fp, previous_fp) =
332 secrets.compute_fingerprints_for_validation("TOKEN", &salt_config);
333
334 assert!(current_fp.is_some());
335 assert!(previous_fp.is_none());
336 }
337
338 #[test]
339 fn test_compute_fingerprints_for_validation_only_previous() {
340 let mut secrets = ResolvedSecrets::new();
341 secrets
342 .values
343 .insert("TOKEN".to_string(), "abc123".to_string());
344
345 let salt_config = SaltConfig::with_rotation(None, Some("only-previous".to_string()));
346
347 let (current_fp, previous_fp) =
348 secrets.compute_fingerprints_for_validation("TOKEN", &salt_config);
349
350 assert!(current_fp.is_none());
351 assert!(previous_fp.is_some());
352 }
353
354 #[test]
355 fn test_compute_fingerprints_for_validation_missing_secret() {
356 let secrets = ResolvedSecrets::new();
357 let salt_config = SaltConfig::new(Some("salt".to_string()));
358
359 let (current_fp, previous_fp) =
360 secrets.compute_fingerprints_for_validation("MISSING", &salt_config);
361
362 assert!(current_fp.is_none());
363 assert!(previous_fp.is_none());
364 }
365
366 #[test]
367 fn test_compute_fingerprints_for_validation_no_salt() {
368 let mut secrets = ResolvedSecrets::new();
369 secrets
370 .values
371 .insert("KEY".to_string(), "value".to_string());
372
373 let salt_config = SaltConfig::default();
374
375 let (current_fp, previous_fp) =
376 secrets.compute_fingerprints_for_validation("KEY", &salt_config);
377
378 assert!(current_fp.is_none());
379 assert!(previous_fp.is_none());
380 }
381
382 #[test]
383 fn test_resolved_secrets_clone() {
384 let mut secrets = ResolvedSecrets::new();
385 secrets.values.insert("K1".to_string(), "V1".to_string());
386 secrets
387 .fingerprints
388 .insert("K1".to_string(), "FP1".to_string());
389
390 let cloned = secrets.clone();
391 assert_eq!(cloned.values.get("K1"), Some(&"V1".to_string()));
392 assert_eq!(cloned.fingerprints.get("K1"), Some(&"FP1".to_string()));
393 }
394
395 #[test]
396 fn test_resolved_secrets_debug() {
397 let secrets = ResolvedSecrets::new();
398 let debug = format!("{secrets:?}");
399 assert!(debug.contains("ResolvedSecrets"));
400 }
401
402 #[test]
403 fn test_multiple_secrets() {
404 let mut secrets = ResolvedSecrets::new();
405 secrets
406 .values
407 .insert("KEY1".to_string(), "value1".to_string());
408 secrets
409 .values
410 .insert("KEY2".to_string(), "value2".to_string());
411 secrets
412 .values
413 .insert("KEY3".to_string(), "value3".to_string());
414
415 assert_eq!(secrets.values.len(), 3);
416 assert!(!secrets.is_empty());
417 assert_eq!(secrets.get("KEY1"), Some("value1"));
418 assert_eq!(secrets.get("KEY2"), Some("value2"));
419 assert_eq!(secrets.get("KEY3"), Some("value3"));
420 }
421}