1use std::collections::BTreeMap;
38use std::fmt;
39use std::collections::hash_map::DefaultHasher;
43use std::hash::{Hash, Hasher};
44use std::path::Path;
45
46use crate::value::Value;
47
48use crate::error::{Error, ErrorKind, Origin};
49use crate::snapshot::Snapshot;
50use crate::source::Format;
51
52type Document = BTreeMap<String, Value>;
54
55const CACHED: &str = "cached";
57
58const MARKER: &str = "__dynamic_config_cache";
67
68const FINGERPRINT: &str = "fingerprint";
70
71const KEYS: &str = "keys";
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[non_exhaustive]
95pub enum CacheMode {
96 #[default]
102 Full,
103 Redacted,
109 Fingerprint,
115}
116
117impl fmt::Display for CacheMode {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(match self {
120 Self::Full => "full",
121 Self::Redacted => "redacted",
122 Self::Fingerprint => "fingerprint",
123 })
124 }
125}
126
127impl CacheMode {
128 #[must_use]
130 pub fn recovers(self) -> bool {
131 !matches!(self, Self::Fingerprint)
132 }
133}
134
135#[derive(Debug)]
137pub enum Recovery {
138 Usable(Snapshot),
140 Drift(Option<Vec<String>>),
147 Absent,
149}
150
151pub(crate) fn write(
160 snapshot: &Snapshot,
161 path: &Path,
162 mode: CacheMode,
163 secrets: &[&str],
164) -> Result<(), Error> {
165 let format = format_of(path)?;
166
167 let mut document = match mode {
168 CacheMode::Full => as_document(snapshot),
169 CacheMode::Redacted => without(&as_document(snapshot), secrets),
170 CacheMode::Fingerprint => fingerprint_document(snapshot),
171 };
172
173 let mut marker = Document::new();
174 marker.insert("version".to_owned(), Value::Integer(1));
175 marker.insert(
176 "mode".to_owned(),
177 Value::String(
178 match mode {
179 CacheMode::Full => "full",
180 CacheMode::Redacted => "redacted",
181 CacheMode::Fingerprint => "fingerprint",
182 }
183 .to_owned(),
184 ),
185 );
186 document.insert(MARKER.to_owned(), Value::Table(marker));
187
188 crate::write::save_dict(&document, path, format, CACHED)
189}
190
191#[cfg(feature = "decrypt")]
198pub(crate) fn write_encrypted(
199 snapshot: &Snapshot,
200 path: &Path,
201 encryptor: &dyn crate::Encryptor,
202) -> Result<(), Error> {
203 let format = encrypted_format_of(path)?;
204
205 let mut document = as_document(snapshot);
206 let mut marker = Document::new();
207 marker.insert("version".to_owned(), Value::Integer(1));
208 marker.insert("mode".to_owned(), Value::String("full".to_owned()));
209 document.insert(MARKER.to_owned(), Value::Table(marker));
210
211 crate::write::save_dict_encrypted(&document, path, format, CACHED, encryptor)
212}
213
214#[cfg(feature = "decrypt")]
218pub(crate) fn read_encrypted(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
219 let format = encrypted_format_of(path)?;
220
221 let bytes = match std::fs::read(path) {
222 Ok(bytes) => bytes,
223 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
224 Err(error) => {
225 return Err(Error::new(ErrorKind::Io, error.to_string())
226 .with_origin(Origin::File(path.to_owned())))
227 }
228 };
229
230 let plaintext = crate::decrypt::decrypt(&bytes, &path.display().to_string())?;
233
234 parse_cache(plaintext.text(), format, path, current)
235}
236
237#[cfg(feature = "decrypt")]
239fn encrypted_format_of(path: &Path) -> Result<crate::Format, Error> {
240 let name = path
241 .to_str()
242 .ok_or_else(|| Error::new(ErrorKind::Io, "the cache path is not valid UTF-8"))?;
243
244 let Some((inner, _suffix)) = crate::source::inner_name(name) else {
245 return Err(Error::new(
246 ErrorKind::Backend,
247 format!(
248 "an encrypted cache path carries the format under the \
249 encryption suffix — `last.json.{}`, not `{name}`",
250 crate::source::ENCRYPTED_SUFFIX
251 ),
252 ));
253 };
254
255 format_of(Path::new(inner))
256}
257
258pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
265 let format = format_of(path)?;
266
267 let text = match std::fs::read_to_string(path) {
268 Ok(text) => text,
269 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
270 Err(error) => {
271 return Err(Error::new(ErrorKind::Io, error.to_string())
272 .with_origin(Origin::File(path.to_owned())))
273 }
274 };
275
276 parse_cache(&text, format, path, current)
277}
278
279fn parse_cache(
281 text: &str,
282 format: crate::Format,
283 path: &Path,
284 current: Option<&Snapshot>,
285) -> Result<Recovery, Error> {
286 let _ = path;
287 let sources = [crate::Source::inline(text, format)];
288 let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
289
290 let is_fingerprint = match cached.get::<String>(&format!("{MARKER}.mode")) {
294 Ok(mode) => mode == "fingerprint",
295 Err(_) => cached.contains(FINGERPRINT),
296 };
297
298 if !is_fingerprint {
299 return Ok(Recovery::Usable(cached.without_top_level(MARKER)));
300 }
301
302 Ok(Recovery::Drift(drift(&cached, current)))
303}
304
305fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Option<Vec<String>> {
312 let current = current?;
313
314 let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
315 let after = current.leaf_paths();
316
317 let mut moved: Vec<String> = before
318 .iter()
319 .filter(|key| !after.contains(key))
320 .map(|key| format!("{key} is gone"))
321 .chain(
322 after
323 .iter()
324 .filter(|key| !before.contains(key))
325 .map(|key| format!("{key} is new")),
326 )
327 .collect();
328
329 moved.sort();
330
331 if moved.is_empty() {
335 let stored: Option<String> = cached.get(FINGERPRINT).ok();
336 let current_hash = fingerprint_of(current);
337
338 if stored.as_deref() == Some(current_hash.as_str()) {
339 return Some(vec!["nothing moved — the sources match the last good \
340 configuration exactly"
341 .to_owned()]);
342 }
343
344 return Some(vec!["the same keys, with different values".to_owned()]);
345 }
346
347 Some(moved)
348}
349
350fn fingerprint_of(snapshot: &Snapshot) -> String {
358 let mut hasher = DefaultHasher::new();
359 snapshot.to_value().hash(&mut hasher);
360
361 format!("{:016x}", hasher.finish())
362}
363
364fn without(values: &Document, secrets: &[&str]) -> Document {
379 let mut document = values.clone();
380
381 for secret in secrets {
382 remove_path(&mut document, secret);
383 }
384
385 document
386}
387
388fn remove_path(document: &mut Document, path: &str) {
390 match path.split_once('.') {
391 None => {
392 document.remove(path);
393 }
394 Some((head, rest)) => {
395 if let Some(Value::Table(nested)) = document.get_mut(head) {
396 remove_path(nested, rest);
397 }
398 }
399 }
400}
401
402fn as_document(snapshot: &Snapshot) -> Document {
404 match snapshot.to_value() {
405 Value::Table(table) => table,
406 _ => Document::new(),
409 }
410}
411
412fn fingerprint_document(snapshot: &Snapshot) -> Document {
414 let keys = snapshot.leaf_paths();
415
416 let mut document = Document::new();
417 document.insert(
418 FINGERPRINT.to_owned(),
419 Value::String(fingerprint_of(snapshot)),
420 );
421 document.insert(
422 KEYS.to_owned(),
423 Value::Array(keys.into_iter().map(Value::String).collect()),
424 );
425
426 document
427}
428
429fn format_of(path: &Path) -> Result<Format, Error> {
430 if path.extension().is_some_and(|extension| extension == "age") {
434 return Err(Error::new(
435 ErrorKind::Backend,
436 format!(
437 "{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
438 path.display()
439 ),
440 ));
441 }
442
443 path.extension()
444 .and_then(|extension| extension.to_str())
445 .and_then(Format::from_extension)
446 .ok_or_else(|| Error::unsupported(path))
447}
448
449#[cfg(all(test, feature = "json"))]
451fn dict_of(entries: &[(&str, crate::Value)]) -> BTreeMap<String, crate::Value> {
452 entries
453 .iter()
454 .map(|(key, value)| ((*key).to_owned(), value.clone()))
455 .collect()
456}
457
458#[cfg(all(test, feature = "json"))]
459mod tests {
460 use super::*;
461
462 use crate::Value;
466
467 fn scratch(test: &str) -> std::path::PathBuf {
468 let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
469
470 let _ = std::fs::remove_dir_all(&directory);
471 std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
472
473 directory.join("cache.json")
474 }
475
476 fn snapshot() -> Snapshot {
477 Snapshot::new(dict_of(&[
478 ("host", "localhost".into()),
479 ("password", "hunter2".into()),
480 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
481 ]))
482 }
483
484 #[test]
485 fn full_keeps_everything_and_recovers() {
486 let path = scratch("full");
487
488 write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
489
490 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
491 panic!("a full cache must be usable");
492 };
493
494 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
495 assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
496 assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
497 }
498
499 #[test]
500 fn redacted_drops_the_marked_fields_and_nothing_else() {
501 let path = scratch("redacted");
502
503 write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
504
505 let written = std::fs::read_to_string(&path).unwrap();
506 assert!(!written.contains("hunter2"), "{written}");
507
508 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
509 panic!("a redacted cache is still usable");
510 };
511
512 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
513 assert!(
514 !recovered.contains("password"),
515 "the secret must not have survived"
516 );
517 }
518
519 #[test]
520 fn fingerprint_writes_no_value_at_all() {
521 let path = scratch("fingerprint");
522
523 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
524
525 let written = std::fs::read_to_string(&path).unwrap();
526
527 assert!(!written.contains("hunter2"), "{written}");
528 assert!(!written.contains("localhost"), "{written}");
529 assert!(written.contains("host"), "{written}");
531 }
532
533 #[test]
534 fn fingerprint_cannot_recover_but_reports_what_moved() {
535 let path = scratch("drift");
536
537 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
538
539 let now = Snapshot::new(dict_of(&[
540 ("host", "localhost".into()),
541 ("hsot", "typo".into()),
542 ]));
543
544 let Recovery::Drift(Some(moved)) = read(&path, Some(&now)).unwrap() else {
545 panic!("a fingerprint cache cannot be usable");
546 };
547
548 assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
549 assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
550 }
551
552 fn known_document() -> Snapshot {
555 Snapshot::new(dict_of(&[
556 ("host", "localhost".into()),
557 ("port", Value::from(5432u16)),
558 ("ratio", Value::from(0.5f64)),
559 ("tls", Value::from(true)),
560 (
561 "tags",
562 Value::from(vec![Value::from("a"), Value::from("b")]),
563 ),
564 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
565 ]))
566 }
567
568 #[test]
578 fn the_fingerprint_of_a_known_document_is_this_one() {
579 assert_eq!(fingerprint_of(&known_document()), "67d38230cb74f238");
580 }
581
582 #[test]
583 fn a_fingerprint_is_stable_within_a_process() {
584 assert_eq!(
585 fingerprint_of(&known_document()),
586 fingerprint_of(&known_document())
587 );
588 }
589
590 #[test]
594 fn the_same_number_at_two_widths_fingerprints_the_same() {
595 let narrow = Snapshot::new(dict_of(&[("max", Value::from(10u16))]));
596 let wide = Snapshot::new(dict_of(&[("max", Value::from(10u64))]));
597
598 assert_eq!(fingerprint_of(&narrow), fingerprint_of(&wide));
599 }
600
601 #[test]
602 fn a_signed_zero_is_a_different_document() {
603 let negative = Snapshot::new(dict_of(&[("bias", Value::from(-0.0f64))]));
604 let positive = Snapshot::new(dict_of(&[("bias", Value::from(0.0f64))]));
605
606 assert_ne!(fingerprint_of(&negative), fingerprint_of(&positive));
607 }
608
609 #[test]
612 fn a_fingerprint_still_matches_after_a_round_trip_through_the_file() {
613 let path = scratch("round-trip");
614
615 write(&known_document(), &path, CacheMode::Fingerprint, &[]).unwrap();
616
617 let Recovery::Drift(Some(report)) = read(&path, Some(&known_document())).unwrap() else {
618 panic!("a fingerprint cache cannot be usable");
619 };
620
621 assert_eq!(report.len(), 1);
622 assert!(report[0].contains("nothing moved"), "{report:?}");
623 }
624
625 #[test]
626 fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
627 let error = write(
628 &snapshot(),
629 Path::new("cache.json.age"),
630 CacheMode::Full,
631 &[],
632 )
633 .unwrap_err();
634
635 assert!(error.to_string().contains("plaintext"), "{error}");
636 }
637
638 #[test]
639 fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
640 let path = scratch("absent").with_file_name("nothing.json");
641
642 assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
643 }
644
645 #[test]
646 fn only_fingerprint_refuses_to_recover() {
647 assert!(CacheMode::Full.recovers());
648 assert!(CacheMode::Redacted.recovers());
649 assert!(!CacheMode::Fingerprint.recovers());
650 }
651}