dynamic_config/cache.rs
1//! Starting from the last configuration that worked.
2//!
3//! A process that cannot read its configuration should normally refuse to
4//! start — that is the point of failing loudly. But there is one case where
5//! refusing is worse: a machine reboots, something on disk is half-written or
6//! a mount has not appeared yet, and a service that would otherwise have come
7//! up sits dead until a person notices.
8//!
9//! Opting into a cache says: prefer running on yesterday's configuration to not
10//! running at all. It is deliberately opt-in, and deliberately loud — recovery
11//! logs a warning every time, because a service quietly running on a stale
12//! configuration is its own kind of outage.
13//!
14//! ## What ends up on disk
15//!
16//! A resolved configuration holds every value, including the ones
17//! `#[config(secret)]` exists to keep out of logs. There is no way to make that
18//! not a trade-off, so it is a choice with three answers rather than a default
19//! nobody was told about:
20//!
21//! | Mode | On disk | Recovers |
22//! |---|---|---|
23//! | [`Full`](CacheMode::Full) | everything, secrets included | completely |
24//! | [`Redacted`](CacheMode::Redacted) *(the attribute's default)* | everything except `#[config(secret)]` fields | only if the secrets come from somewhere live |
25//! | [`Fingerprint`](CacheMode::Fingerprint) | a hash and the key names | never — it reports what changed and still fails |
26//!
27//! On Unix the file is written `0600`. That is the most that can be done
28//! without refusing the request.
29//!
30//! ## Recovery reads no files
31//!
32//! The files are what broke. Recovery loads from the cache plus the
33//! environment and the runtime layers — never from the sources whose failure
34//! caused it, because a malformed file fails to parse whatever sits underneath
35//! it.
36
37use std::collections::BTreeMap;
38use std::fmt;
39use std::path::Path;
40
41use crate::value::Value;
42
43use crate::error::{Error, ErrorKind, Origin};
44use crate::snapshot::Snapshot;
45use crate::source::Format;
46
47/// A cache file's contents: keys to values, in this crate's tree.
48type Document = BTreeMap<String, Value>;
49
50/// The key a cache document is written under, so it reads back like any file.
51const CACHED: &str = "cached";
52
53/// The marker every cache document carries, naming what it is.
54///
55/// The reader used to *sniff*: "has a top-level `fingerprint` key" meant
56/// "is a fingerprint document" — and a real configuration with a
57/// `fingerprint` section (a TLS pin, an image digest) was misread as one,
58/// turning a perfectly good full cache into a refusal to start. A document
59/// that says what it is cannot be misread. `version` is there so a future
60/// format change can tell old files from new ones.
61const MARKER: &str = "__dynamic_config_cache";
62
63/// Where the fingerprint lives inside a `Fingerprint` document.
64const FINGERPRINT: &str = "fingerprint";
65
66/// Where the key list lives inside a `Fingerprint` document.
67const KEYS: &str = "keys";
68
69/// How much of a configuration to keep on disk.
70///
71/// A resolved configuration holds every value, including the ones
72/// `#[config(secret)]` exists to keep out of logs. There is no way to make that
73/// not a trade-off, so it is a choice with three answers rather than a default
74/// nobody was told about:
75///
76/// | Mode | On disk | Recovers |
77/// |---|---|---|
78/// | [`Full`](Self::Full) | everything, secrets included | completely |
79/// | [`Redacted`](Self::Redacted) *(the attribute's default)* | everything except `#[config(secret)]` fields | only if the secrets come from somewhere live |
80/// | [`Fingerprint`](Self::Fingerprint) | a hash and the key names | never — it reports what changed and still fails |
81///
82/// On Unix the file is written `0600`. That is the most that can be done
83/// without refusing the request.
84///
85/// Recovery reads no files: the files are what broke, so it loads from the
86/// cache plus the environment and the runtime layers, never from the sources
87/// whose failure caused it.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89#[non_exhaustive]
90pub enum CacheMode {
91 /// Everything, secrets included. Recovers completely.
92 ///
93 /// The default, because a cache that cannot recover is a cache that will
94 /// disappoint somebody at three in the morning. The file is `0600`; the
95 /// rest is documented rather than solved.
96 #[default]
97 Full,
98 /// Everything except the fields marked `#[config(secret)]`.
99 ///
100 /// Recovery then depends on those values arriving from somewhere live —
101 /// the environment, usually. That is arguably the right deployment shape
102 /// anyway, and useless for anyone whose secrets live in a file.
103 Redacted,
104 /// A hash and the key names. No values at all.
105 ///
106 /// Cannot recover, and does not pretend to: a failed start still fails.
107 /// What it buys is the diagnosis — *which keys have moved since the last
108 /// time this worked* — which is usually the first thing anyone wants.
109 Fingerprint,
110}
111
112impl fmt::Display for CacheMode {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 f.write_str(match self {
115 Self::Full => "full",
116 Self::Redacted => "redacted",
117 Self::Fingerprint => "fingerprint",
118 })
119 }
120}
121
122impl CacheMode {
123 /// Whether a cache in this mode can stand in for the real thing.
124 #[must_use]
125 pub fn recovers(self) -> bool {
126 !matches!(self, Self::Fingerprint)
127 }
128}
129
130/// What a cache file turned out to hold.
131#[derive(Debug)]
132pub enum Recovery {
133 /// A configuration to start from.
134 Usable(Snapshot),
135 /// Only a fingerprint: what differs from the last good state.
136 ///
137 /// `Some` lists the key paths that differ — or one explanatory sentence
138 /// when the keys match and only values moved. `None` means the comparison
139 /// itself was impossible: the sources do not resolve, so there is nothing
140 /// to compare against.
141 Drift(Option<Vec<String>>),
142 /// No cache on disk yet.
143 Absent,
144}
145
146/// Writes `snapshot` to `path` in `mode`.
147///
148/// `secrets` are the field names to drop in [`CacheMode::Redacted`]; ignored
149/// otherwise.
150///
151/// # Errors
152///
153/// If the path names no supported format, or the file cannot be written.
154pub(crate) fn write(
155 snapshot: &Snapshot,
156 path: &Path,
157 mode: CacheMode,
158 secrets: &[&str],
159) -> Result<(), Error> {
160 let format = format_of(path)?;
161
162 let mut document = match mode {
163 CacheMode::Full => as_document(snapshot),
164 CacheMode::Redacted => without(&as_document(snapshot), secrets),
165 CacheMode::Fingerprint => fingerprint_document(snapshot),
166 };
167
168 let mut marker = Document::new();
169 marker.insert("version".to_owned(), Value::Integer(1));
170 marker.insert(
171 "mode".to_owned(),
172 Value::String(
173 match mode {
174 CacheMode::Full => "full",
175 CacheMode::Redacted => "redacted",
176 CacheMode::Fingerprint => "fingerprint",
177 }
178 .to_owned(),
179 ),
180 );
181 document.insert(MARKER.to_owned(), Value::Table(marker));
182
183 crate::write::save_dict(&document, path, format, CACHED)
184}
185
186/// [`write`], full fidelity, through an [`Encryptor`](crate::Encryptor).
187///
188/// Always the whole document — encryption at rest is what collapses the
189/// full/redacted trade-off, which is the mode's whole reason to exist —
190/// with the same marker a `full` cache carries, so the read side after
191/// decryption is the ordinary read side.
192#[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 = as_document(snapshot);
201 let mut marker = Document::new();
202 marker.insert("version".to_owned(), Value::Integer(1));
203 marker.insert("mode".to_owned(), Value::String("full".to_owned()));
204 document.insert(MARKER.to_owned(), Value::Table(marker));
205
206 crate::write::save_dict_encrypted(&document, path, format, CACHED, encryptor)
207}
208
209/// [`read`], decrypting through the installed
210/// [`Decryptor`](crate::Decryptor) first — the same door
211/// `encrypted_file(..)` reads through, so one installation covers both.
212#[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 // `decrypt` hands back a zeroizing `Plaintext` and has already refused
226 // non-UTF-8, naming the path.
227 let plaintext = crate::decrypt::decrypt(&bytes, &path.display().to_string())?;
228
229 parse_cache(plaintext.text(), format, path, current)
230}
231
232/// The format under the encryption suffix: `last.json.age` is JSON.
233#[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
253/// Reads whatever `path` holds.
254///
255/// # Errors
256///
257/// If the file exists but cannot be read or parsed. A file that is not there is
258/// [`Recovery::Absent`], not a failure — the first start has no cache.
259pub(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
274/// The shared tail of [`read`] and [`read_encrypted`]: text to `Recovery`.
275fn 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 // The marker says what the document is. Files written before the marker
286 // existed (0.0.1) fall back to the old heuristic for one release —
287 // documented in the changelog, removed after it.
288 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
300/// Which keys have appeared or vanished since the cache was written.
301///
302/// `None` means "could not compare": the sources did not resolve — which is
303/// the ordinary case during recovery, since a broken source is *why*
304/// recovery is running. The caller must say so rather than claim a
305/// comparison that never happened.
306fn 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 // The keys all match — that is what the stored hash is FOR: telling
327 // "identical" apart from "same keys, different values". It used to be
328 // written and never read, and the report asserted the comparison anyway.
329 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
345/// The hash of a snapshot's values, as `fingerprint_document` computes it.
346///
347/// Over this crate's own [`Value`](crate::Value) tree, which carries neither
348/// figment's provenance tag nor its numeric widths, so the same document
349/// fingerprints the same however it was assembled. It used to hash the
350/// figment tree's `Debug` rendering, which put the identity of every cache
351/// file on disk at the mercy of an upstream crate's formatting.
352///
353/// **No secrets are masked here**, and that is deliberate: this hash exists
354/// to notice that the live configuration has drifted from the cached one,
355/// and a rotated password is exactly the drift worth noticing. The public
356/// [`fingerprint`](crate::Dynamic::fingerprint) masks them, because its job
357/// is the opposite one — being safe to print.
358fn fingerprint_of(snapshot: &Snapshot) -> String {
359 crate::fingerprint::of(&as_document(snapshot), &[])
360}
361
362/// The whole tree minus the top-level keys named in `secrets`.
363///
364/// Top-level is not a limitation here but a property of the source:
365/// `#[config(secret)]` marks fields of the annotated struct, and those fields
366/// ARE the section's top-level keys. The names arrive serde-resolved — a
367/// `#[serde(rename = "pass")]` secret is redacted under `pass`, the key the
368/// resolved tree actually uses.
369/// The document, minus every secret the type declared.
370///
371/// A secret is named by a plain field — what `#[config(secret)]` on a
372/// struct field produces — or by a dotted path into a nested table, which
373/// is what a language binding derives from a nested model. Both mean the
374/// same thing here: the value at that path does not reach the disk, and
375/// neither does anything under it.
376fn without(values: &Document, secrets: &[&str]) -> Document {
377 let mut document = values.clone();
378
379 for secret in secrets {
380 remove_path(&mut document, secret);
381 }
382
383 document
384}
385
386/// Removes the value at a dotted `path`, if the path leads anywhere.
387fn remove_path(document: &mut Document, path: &str) {
388 match path.split_once('.') {
389 None => {
390 document.remove(path);
391 }
392 Some((head, rest)) => {
393 if let Some(Value::Table(nested)) = document.get_mut(head) {
394 remove_path(nested, rest);
395 }
396 }
397 }
398}
399
400/// The snapshot's tree, as a document.
401fn as_document(snapshot: &Snapshot) -> Document {
402 match snapshot.to_value() {
403 Value::Table(table) => table,
404 // A snapshot is a table by construction — it is the resolved section,
405 // and a section is keys.
406 _ => Document::new(),
407 }
408}
409
410/// A hash of the values, plus the key names — and no value anywhere.
411fn fingerprint_document(snapshot: &Snapshot) -> Document {
412 let keys = snapshot.leaf_paths();
413
414 let mut document = Document::new();
415 document.insert(
416 FINGERPRINT.to_owned(),
417 Value::String(fingerprint_of(snapshot)),
418 );
419 document.insert(
420 KEYS.to_owned(),
421 Value::Array(keys.into_iter().map(Value::String).collect()),
422 );
423
424 document
425}
426
427fn format_of(path: &Path) -> Result<Format, Error> {
428 // Named before the generic "unsupported" because the mistake is specific:
429 // the cache writes plaintext, and a `.age` name would promise otherwise.
430 // Failing loudly here beats a file that says "encrypted" and is not.
431 if path.extension().is_some_and(|extension| extension == "age") {
432 return Err(Error::new(
433 ErrorKind::Backend,
434 format!(
435 "{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
436 path.display()
437 ),
438 ));
439 }
440
441 path.extension()
442 .and_then(|extension| extension.to_str())
443 .and_then(Format::from_extension)
444 .ok_or_else(|| Error::unsupported(path))
445}
446
447/// A map, for the tests below.
448#[cfg(all(test, feature = "json"))]
449fn dict_of(entries: &[(&str, crate::Value)]) -> BTreeMap<String, crate::Value> {
450 entries
451 .iter()
452 .map(|(key, value)| ((*key).to_owned(), value.clone()))
453 .collect()
454}
455
456#[cfg(all(test, feature = "json"))]
457mod tests {
458 use super::*;
459
460 // The tree these tests build is the resolved one, not the document the
461 // writers take — the explicit import shadows the glob's so `Value` means
462 // the same thing here as it does everywhere a snapshot is held.
463 use crate::Value;
464
465 fn scratch(test: &str) -> std::path::PathBuf {
466 let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
467
468 let _ = std::fs::remove_dir_all(&directory);
469 std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
470
471 directory.join("cache.json")
472 }
473
474 fn snapshot() -> Snapshot {
475 Snapshot::new(dict_of(&[
476 ("host", "localhost".into()),
477 ("password", "hunter2".into()),
478 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
479 ]))
480 }
481
482 #[test]
483 fn full_keeps_everything_and_recovers() {
484 let path = scratch("full");
485
486 write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
487
488 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
489 panic!("a full cache must be usable");
490 };
491
492 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
493 assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
494 assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
495 }
496
497 #[test]
498 fn redacted_drops_the_marked_fields_and_nothing_else() {
499 let path = scratch("redacted");
500
501 write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
502
503 let written = std::fs::read_to_string(&path).unwrap();
504 assert!(!written.contains("hunter2"), "{written}");
505
506 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
507 panic!("a redacted cache is still usable");
508 };
509
510 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
511 assert!(
512 !recovered.contains("password"),
513 "the secret must not have survived"
514 );
515 }
516
517 #[test]
518 fn fingerprint_writes_no_value_at_all() {
519 let path = scratch("fingerprint");
520
521 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
522
523 let written = std::fs::read_to_string(&path).unwrap();
524
525 assert!(!written.contains("hunter2"), "{written}");
526 assert!(!written.contains("localhost"), "{written}");
527 // The key names are there; the values are not.
528 assert!(written.contains("host"), "{written}");
529 }
530
531 #[test]
532 fn fingerprint_cannot_recover_but_reports_what_moved() {
533 let path = scratch("drift");
534
535 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
536
537 let now = Snapshot::new(dict_of(&[
538 ("host", "localhost".into()),
539 ("hsot", "typo".into()),
540 ]));
541
542 let Recovery::Drift(Some(moved)) = read(&path, Some(&now)).unwrap() else {
543 panic!("a fingerprint cache cannot be usable");
544 };
545
546 assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
547 assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
548 }
549
550 /// The document the hard-coded fingerprint below is taken over. Fixed
551 /// on purpose: every scalar shape the tree can hold, nested once.
552 fn known_document() -> Snapshot {
553 Snapshot::new(dict_of(&[
554 ("host", "localhost".into()),
555 ("port", Value::from(5432u16)),
556 ("ratio", Value::from(0.5f64)),
557 ("tls", Value::from(true)),
558 (
559 "tags",
560 Value::from(vec![Value::from("a"), Value::from("b")]),
561 ),
562 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
563 ]))
564 }
565
566 /// The value in this assertion is not derived from anything: it is
567 /// written down so that *changing the algorithm is loud*. A fingerprint
568 /// is a cache file's identity, and a silent change to it means every
569 /// cache on disk stops being recognised — the conservative direction,
570 /// but not one to discover from a support ticket. If this fails, the
571 /// fingerprint moved: decide whether that was intended, say so in the
572 /// changelog under Changed, and write the new number down.
573 ///
574 /// It moved once, in 0.10, and this is what it caught. The hash was
575 /// `std`'s `DefaultHasher`, whose implementation carries no guarantee
576 /// of staying the same across a Rust release — so the identity of every
577 /// cache file on disk was one toolchain upgrade away from changing for
578 /// nobody's reason. It is SHA-256 over a written-out encoding now, and
579 /// the only thing that can move it is a decision made here.
580 #[test]
581 fn the_fingerprint_of_a_known_document_is_this_one() {
582 assert_eq!(
583 fingerprint_of(&known_document()),
584 "9f6aba580a7e821ad7afe62b3e7d355ad066401ed1115a6a95981ad961f5fbf0"
585 );
586 }
587
588 #[test]
589 fn a_fingerprint_is_stable_within_a_process() {
590 assert_eq!(
591 fingerprint_of(&known_document()),
592 fingerprint_of(&known_document())
593 );
594 }
595
596 /// The width a provider chose is not part of the document, so it must
597 /// not be part of its identity: a cache written when `max` arrived as a
598 /// `u16` has to still be recognised when it arrives as a `u64`.
599 #[test]
600 fn the_same_number_at_two_widths_fingerprints_the_same() {
601 let narrow = Snapshot::new(dict_of(&[("max", Value::from(10u16))]));
602 let wide = Snapshot::new(dict_of(&[("max", Value::from(10u64))]));
603
604 assert_eq!(fingerprint_of(&narrow), fingerprint_of(&wide));
605 }
606
607 #[test]
608 fn a_signed_zero_is_a_different_document() {
609 let negative = Snapshot::new(dict_of(&[("bias", Value::from(-0.0f64))]));
610 let positive = Snapshot::new(dict_of(&[("bias", Value::from(0.0f64))]));
611
612 assert_ne!(fingerprint_of(&negative), fingerprint_of(&positive));
613 }
614
615 /// The whole round trip the fingerprint mode exists for: written to
616 /// disk, read back, and compared against the same sources.
617 #[test]
618 fn a_fingerprint_still_matches_after_a_round_trip_through_the_file() {
619 let path = scratch("round-trip");
620
621 write(&known_document(), &path, CacheMode::Fingerprint, &[]).unwrap();
622
623 let Recovery::Drift(Some(report)) = read(&path, Some(&known_document())).unwrap() else {
624 panic!("a fingerprint cache cannot be usable");
625 };
626
627 assert_eq!(report.len(), 1);
628 assert!(report[0].contains("nothing moved"), "{report:?}");
629 }
630
631 #[test]
632 fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
633 let error = write(
634 &snapshot(),
635 Path::new("cache.json.age"),
636 CacheMode::Full,
637 &[],
638 )
639 .unwrap_err();
640
641 assert!(error.to_string().contains("plaintext"), "{error}");
642 }
643
644 #[test]
645 fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
646 let path = scratch("absent").with_file_name("nothing.json");
647
648 assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
649 }
650
651 #[test]
652 fn only_fingerprint_refuses_to_recover() {
653 assert!(CacheMode::Full.recovers());
654 assert!(CacheMode::Redacted.recovers());
655 assert!(!CacheMode::Fingerprint.recovers());
656 }
657}