1#[cfg(all(test, feature = "json"))]
38use std::collections::BTreeMap;
39use std::fmt;
40use std::collections::hash_map::DefaultHasher;
44use std::hash::{Hash, Hasher};
45use std::path::Path;
46
47use figment::value::{Dict, Value};
48
49use crate::error::{Error, ErrorKind, Origin};
50use crate::snapshot::Snapshot;
51use crate::source::Format;
52
53const CACHED: &str = "cached";
55
56const MARKER: &str = "__dynamic_config_cache";
65
66const FINGERPRINT: &str = "fingerprint";
68
69const KEYS: &str = "keys";
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92#[non_exhaustive]
93pub enum CacheMode {
94 #[default]
100 Full,
101 Redacted,
107 Fingerprint,
113}
114
115impl fmt::Display for CacheMode {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.write_str(match self {
118 Self::Full => "full",
119 Self::Redacted => "redacted",
120 Self::Fingerprint => "fingerprint",
121 })
122 }
123}
124
125impl CacheMode {
126 #[must_use]
128 pub fn recovers(self) -> bool {
129 !matches!(self, Self::Fingerprint)
130 }
131}
132
133#[derive(Debug)]
135pub enum Recovery {
136 Usable(Snapshot),
138 Drift(Option<Vec<String>>),
145 Absent,
147}
148
149pub(crate) fn write(
158 snapshot: &Snapshot,
159 path: &Path,
160 mode: CacheMode,
161 secrets: &[&str],
162) -> Result<(), Error> {
163 let format = format_of(path)?;
164
165 let mut document = match mode {
166 CacheMode::Full => snapshot.values().clone(),
167 CacheMode::Redacted => without(snapshot.values(), secrets),
168 CacheMode::Fingerprint => fingerprint_document(snapshot),
169 };
170
171 let mut marker = Dict::new();
172 marker.insert("version".to_owned(), Value::from(1));
173 marker.insert(
174 "mode".to_owned(),
175 Value::from(match mode {
176 CacheMode::Full => "full",
177 CacheMode::Redacted => "redacted",
178 CacheMode::Fingerprint => "fingerprint",
179 }),
180 );
181 document.insert(MARKER.to_owned(), Value::from(marker));
182
183 crate::write::save_dict(&document, path, format, CACHED)
184}
185
186#[cfg(feature = "decrypt")]
193pub(crate) fn write_encrypted(
194 snapshot: &Snapshot,
195 path: &Path,
196 encryptor: &dyn crate::Encryptor,
197) -> Result<(), Error> {
198 let format = encrypted_format_of(path)?;
199
200 let mut document = snapshot.values().clone();
201 let mut marker = Dict::new();
202 marker.insert("version".to_owned(), Value::from(1));
203 marker.insert("mode".to_owned(), Value::from("full"));
204 document.insert(MARKER.to_owned(), Value::from(marker));
205
206 crate::write::save_dict_encrypted(&document, path, format, CACHED, encryptor)
207}
208
209#[cfg(feature = "decrypt")]
213pub(crate) fn read_encrypted(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
214 let format = encrypted_format_of(path)?;
215
216 let bytes = match std::fs::read(path) {
217 Ok(bytes) => bytes,
218 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
219 Err(error) => {
220 return Err(Error::new(ErrorKind::Io, error.to_string())
221 .with_origin(Origin::File(path.to_owned())))
222 }
223 };
224
225 let plaintext = crate::decrypt::decrypt(&bytes, &path.display().to_string())?;
228
229 parse_cache(plaintext.text(), format, path, current)
230}
231
232#[cfg(feature = "decrypt")]
234fn encrypted_format_of(path: &Path) -> Result<crate::Format, Error> {
235 let name = path
236 .to_str()
237 .ok_or_else(|| Error::new(ErrorKind::Io, "the cache path is not valid UTF-8"))?;
238
239 let Some((inner, _suffix)) = crate::source::inner_name(name) else {
240 return Err(Error::new(
241 ErrorKind::Backend,
242 format!(
243 "an encrypted cache path carries the format under the \
244 encryption suffix — `last.json.{}`, not `{name}`",
245 crate::source::ENCRYPTED_SUFFIX
246 ),
247 ));
248 };
249
250 format_of(Path::new(inner))
251}
252
253pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
260 let format = format_of(path)?;
261
262 let text = match std::fs::read_to_string(path) {
263 Ok(text) => text,
264 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
265 Err(error) => {
266 return Err(Error::new(ErrorKind::Io, error.to_string())
267 .with_origin(Origin::File(path.to_owned())))
268 }
269 };
270
271 parse_cache(&text, format, path, current)
272}
273
274fn parse_cache(
276 text: &str,
277 format: crate::Format,
278 path: &Path,
279 current: Option<&Snapshot>,
280) -> Result<Recovery, Error> {
281 let _ = path;
282 let sources = [crate::Source::inline(text, format)];
283 let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
284
285 let is_fingerprint = match cached.get::<String>(&format!("{MARKER}.mode")) {
289 Ok(mode) => mode == "fingerprint",
290 Err(_) => cached.contains(FINGERPRINT),
291 };
292
293 if !is_fingerprint {
294 return Ok(Recovery::Usable(cached.without_top_level(MARKER)));
295 }
296
297 Ok(Recovery::Drift(drift(&cached, current)))
298}
299
300fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Option<Vec<String>> {
307 let current = current?;
308
309 let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
310 let after = current.leaf_paths();
311
312 let mut moved: Vec<String> = before
313 .iter()
314 .filter(|key| !after.contains(key))
315 .map(|key| format!("{key} is gone"))
316 .chain(
317 after
318 .iter()
319 .filter(|key| !before.contains(key))
320 .map(|key| format!("{key} is new")),
321 )
322 .collect();
323
324 moved.sort();
325
326 if moved.is_empty() {
330 let stored: Option<String> = cached.get(FINGERPRINT).ok();
331 let current_hash = fingerprint_of(current);
332
333 if stored.as_deref() == Some(current_hash.as_str()) {
334 return Some(vec!["nothing moved — the sources match the last good \
335 configuration exactly"
336 .to_owned()]);
337 }
338
339 return Some(vec!["the same keys, with different values".to_owned()]);
340 }
341
342 Some(moved)
343}
344
345fn fingerprint_of(snapshot: &Snapshot) -> String {
353 let mut hasher = DefaultHasher::new();
354 snapshot.to_value().hash(&mut hasher);
355
356 format!("{:016x}", hasher.finish())
357}
358
359fn without(values: &Dict, secrets: &[&str]) -> Dict {
374 let mut document = values.clone();
375
376 for secret in secrets {
377 remove_path(&mut document, secret);
378 }
379
380 document
381}
382
383fn remove_path(document: &mut Dict, path: &str) {
385 match path.split_once('.') {
386 None => {
387 document.remove(path);
388 }
389 Some((head, rest)) => {
390 if let Some(Value::Dict(_, nested)) = document.get_mut(head) {
391 remove_path(nested, rest);
392 }
393 }
394 }
395}
396
397fn fingerprint_document(snapshot: &Snapshot) -> Dict {
399 let keys = snapshot.leaf_paths();
400
401 let mut document = Dict::new();
402 document.insert(
403 FINGERPRINT.to_owned(),
404 Value::from(fingerprint_of(snapshot)),
405 );
406 document.insert(
407 KEYS.to_owned(),
408 Value::from(keys.into_iter().map(Value::from).collect::<Vec<_>>()),
409 );
410
411 document
412}
413
414fn format_of(path: &Path) -> Result<Format, Error> {
415 if path.extension().is_some_and(|extension| extension == "age") {
419 return Err(Error::new(
420 ErrorKind::Backend,
421 format!(
422 "{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
423 path.display()
424 ),
425 ));
426 }
427
428 path.extension()
429 .and_then(|extension| extension.to_str())
430 .and_then(Format::from_extension)
431 .ok_or_else(|| Error::unsupported(path))
432}
433
434#[cfg(all(test, feature = "json"))]
436fn dict_of(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
437 entries
438 .iter()
439 .map(|(key, value)| ((*key).to_owned(), value.clone()))
440 .collect()
441}
442
443#[cfg(all(test, feature = "json"))]
444mod tests {
445 use super::*;
446
447 fn scratch(test: &str) -> std::path::PathBuf {
448 let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
449
450 let _ = std::fs::remove_dir_all(&directory);
451 std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
452
453 directory.join("cache.json")
454 }
455
456 fn snapshot() -> Snapshot {
457 Snapshot::new(dict_of(&[
458 ("host", "localhost".into()),
459 ("password", "hunter2".into()),
460 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
461 ]))
462 }
463
464 #[test]
465 fn full_keeps_everything_and_recovers() {
466 let path = scratch("full");
467
468 write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
469
470 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
471 panic!("a full cache must be usable");
472 };
473
474 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
475 assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
476 assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
477 }
478
479 #[test]
480 fn redacted_drops_the_marked_fields_and_nothing_else() {
481 let path = scratch("redacted");
482
483 write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
484
485 let written = std::fs::read_to_string(&path).unwrap();
486 assert!(!written.contains("hunter2"), "{written}");
487
488 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
489 panic!("a redacted cache is still usable");
490 };
491
492 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
493 assert!(
494 !recovered.contains("password"),
495 "the secret must not have survived"
496 );
497 }
498
499 #[test]
500 fn fingerprint_writes_no_value_at_all() {
501 let path = scratch("fingerprint");
502
503 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
504
505 let written = std::fs::read_to_string(&path).unwrap();
506
507 assert!(!written.contains("hunter2"), "{written}");
508 assert!(!written.contains("localhost"), "{written}");
509 assert!(written.contains("host"), "{written}");
511 }
512
513 #[test]
514 fn fingerprint_cannot_recover_but_reports_what_moved() {
515 let path = scratch("drift");
516
517 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
518
519 let now = Snapshot::new(dict_of(&[
520 ("host", "localhost".into()),
521 ("hsot", "typo".into()),
522 ]));
523
524 let Recovery::Drift(Some(moved)) = read(&path, Some(&now)).unwrap() else {
525 panic!("a fingerprint cache cannot be usable");
526 };
527
528 assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
529 assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
530 }
531
532 fn known_document() -> Snapshot {
535 Snapshot::new(dict_of(&[
536 ("host", "localhost".into()),
537 ("port", Value::from(5432u16)),
538 ("ratio", Value::from(0.5f64)),
539 ("tls", Value::from(true)),
540 (
541 "tags",
542 Value::from(vec![Value::from("a"), Value::from("b")]),
543 ),
544 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
545 ]))
546 }
547
548 #[test]
558 fn the_fingerprint_of_a_known_document_is_this_one() {
559 assert_eq!(fingerprint_of(&known_document()), "67d38230cb74f238");
560 }
561
562 #[test]
563 fn a_fingerprint_is_stable_within_a_process() {
564 assert_eq!(
565 fingerprint_of(&known_document()),
566 fingerprint_of(&known_document())
567 );
568 }
569
570 #[test]
574 fn the_same_number_at_two_widths_fingerprints_the_same() {
575 let narrow = Snapshot::new(dict_of(&[("max", Value::from(10u16))]));
576 let wide = Snapshot::new(dict_of(&[("max", Value::from(10u64))]));
577
578 assert_eq!(fingerprint_of(&narrow), fingerprint_of(&wide));
579 }
580
581 #[test]
582 fn a_signed_zero_is_a_different_document() {
583 let negative = Snapshot::new(dict_of(&[("bias", Value::from(-0.0f64))]));
584 let positive = Snapshot::new(dict_of(&[("bias", Value::from(0.0f64))]));
585
586 assert_ne!(fingerprint_of(&negative), fingerprint_of(&positive));
587 }
588
589 #[test]
592 fn a_fingerprint_still_matches_after_a_round_trip_through_the_file() {
593 let path = scratch("round-trip");
594
595 write(&known_document(), &path, CacheMode::Fingerprint, &[]).unwrap();
596
597 let Recovery::Drift(Some(report)) = read(&path, Some(&known_document())).unwrap() else {
598 panic!("a fingerprint cache cannot be usable");
599 };
600
601 assert_eq!(report.len(), 1);
602 assert!(report[0].contains("nothing moved"), "{report:?}");
603 }
604
605 #[test]
606 fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
607 let error = write(
608 &snapshot(),
609 Path::new("cache.json.age"),
610 CacheMode::Full,
611 &[],
612 )
613 .unwrap_err();
614
615 assert!(error.to_string().contains("plaintext"), "{error}");
616 }
617
618 #[test]
619 fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
620 let path = scratch("absent").with_file_name("nothing.json");
621
622 assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
623 }
624
625 #[test]
626 fn only_fingerprint_refuses_to_recover() {
627 assert!(CacheMode::Full.recovers());
628 assert!(CacheMode::Redacted.recovers());
629 assert!(!CacheMode::Fingerprint.recovers());
630 }
631}