Skip to main content

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;
39// `std::collections::hash_map::DefaultHasher` rather than `std::hash::`: the
40// latter is the same type re-exported, but only since 1.76, and the core
41// crate's floor is 1.71.
42use 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
52/// A cache file's contents: keys to values, in this crate's tree.
53type Document = BTreeMap<String, Value>;
54
55/// The key a cache document is written under, so it reads back like any file.
56const CACHED: &str = "cached";
57
58/// The marker every cache document carries, naming what it is.
59///
60/// The reader used to *sniff*: "has a top-level `fingerprint` key" meant
61/// "is a fingerprint document" — and a real configuration with a
62/// `fingerprint` section (a TLS pin, an image digest) was misread as one,
63/// turning a perfectly good full cache into a refusal to start. A document
64/// that says what it is cannot be misread. `version` is there so a future
65/// format change can tell old files from new ones.
66const MARKER: &str = "__dynamic_config_cache";
67
68/// Where the fingerprint lives inside a `Fingerprint` document.
69const FINGERPRINT: &str = "fingerprint";
70
71/// Where the key list lives inside a `Fingerprint` document.
72const KEYS: &str = "keys";
73
74/// How much of a configuration to keep on disk.
75///
76/// A resolved configuration holds every value, including the ones
77/// `#[config(secret)]` exists to keep out of logs. There is no way to make that
78/// not a trade-off, so it is a choice with three answers rather than a default
79/// nobody was told about:
80///
81/// | Mode | On disk | Recovers |
82/// |---|---|---|
83/// | [`Full`](Self::Full) | everything, secrets included | completely |
84/// | [`Redacted`](Self::Redacted) *(the attribute's default)* | everything except `#[config(secret)]` fields | only if the secrets come from somewhere live |
85/// | [`Fingerprint`](Self::Fingerprint) | a hash and the key names | never — it reports what changed and still fails |
86///
87/// On Unix the file is written `0600`. That is the most that can be done
88/// without refusing the request.
89///
90/// Recovery reads no files: the files are what broke, so it loads from the
91/// cache plus the environment and the runtime layers, never from the sources
92/// whose failure caused it.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94#[non_exhaustive]
95pub enum CacheMode {
96    /// Everything, secrets included. Recovers completely.
97    ///
98    /// The default, because a cache that cannot recover is a cache that will
99    /// disappoint somebody at three in the morning. The file is `0600`; the
100    /// rest is documented rather than solved.
101    #[default]
102    Full,
103    /// Everything except the fields marked `#[config(secret)]`.
104    ///
105    /// Recovery then depends on those values arriving from somewhere live —
106    /// the environment, usually. That is arguably the right deployment shape
107    /// anyway, and useless for anyone whose secrets live in a file.
108    Redacted,
109    /// A hash and the key names. No values at all.
110    ///
111    /// Cannot recover, and does not pretend to: a failed start still fails.
112    /// What it buys is the diagnosis — *which keys have moved since the last
113    /// time this worked* — which is usually the first thing anyone wants.
114    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    /// Whether a cache in this mode can stand in for the real thing.
129    #[must_use]
130    pub fn recovers(self) -> bool {
131        !matches!(self, Self::Fingerprint)
132    }
133}
134
135/// What a cache file turned out to hold.
136#[derive(Debug)]
137pub enum Recovery {
138    /// A configuration to start from.
139    Usable(Snapshot),
140    /// Only a fingerprint: what differs from the last good state.
141    ///
142    /// `Some` lists the key paths that differ — or one explanatory sentence
143    /// when the keys match and only values moved. `None` means the comparison
144    /// itself was impossible: the sources do not resolve, so there is nothing
145    /// to compare against.
146    Drift(Option<Vec<String>>),
147    /// No cache on disk yet.
148    Absent,
149}
150
151/// Writes `snapshot` to `path` in `mode`.
152///
153/// `secrets` are the field names to drop in [`CacheMode::Redacted`]; ignored
154/// otherwise.
155///
156/// # Errors
157///
158/// If the path names no supported format, or the file cannot be written.
159pub(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/// [`write`], full fidelity, through an [`Encryptor`](crate::Encryptor).
192///
193/// Always the whole document — encryption at rest is what collapses the
194/// full/redacted trade-off, which is the mode's whole reason to exist —
195/// with the same marker a `full` cache carries, so the read side after
196/// decryption is the ordinary read side.
197#[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/// [`read`], decrypting through the installed
215/// [`Decryptor`](crate::Decryptor) first — the same door
216/// `encrypted_file(..)` reads through, so one installation covers both.
217#[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    // `decrypt` hands back a zeroizing `Plaintext` and has already refused
231    // non-UTF-8, naming the path.
232    let plaintext = crate::decrypt::decrypt(&bytes, &path.display().to_string())?;
233
234    parse_cache(plaintext.text(), format, path, current)
235}
236
237/// The format under the encryption suffix: `last.json.age` is JSON.
238#[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
258/// Reads whatever `path` holds.
259///
260/// # Errors
261///
262/// If the file exists but cannot be read or parsed. A file that is not there is
263/// [`Recovery::Absent`], not a failure — the first start has no cache.
264pub(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
279/// The shared tail of [`read`] and [`read_encrypted`]: text to `Recovery`.
280fn 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    // The marker says what the document is. Files written before the marker
291    // existed (0.0.1) fall back to the old heuristic for one release —
292    // documented in the changelog, removed after it.
293    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
305/// Which keys have appeared or vanished since the cache was written.
306///
307/// `None` means "could not compare": the sources did not resolve — which is
308/// the ordinary case during recovery, since a broken source is *why*
309/// recovery is running. The caller must say so rather than claim a
310/// comparison that never happened.
311fn 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    // The keys all match — that is what the stored hash is FOR: telling
332    // "identical" apart from "same keys, different values". It used to be
333    // written and never read, and the report asserted the comparison anyway.
334    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
350/// The hash of a snapshot's values, as `fingerprint_document` computes it.
351///
352/// Over this crate's own [`Value`](crate::Value) tree, which carries neither
353/// figment's provenance tag nor its numeric widths, so the same document
354/// fingerprints the same however it was assembled. It used to hash the
355/// figment tree's `Debug` rendering, which put the identity of every cache
356/// file on disk at the mercy of an upstream crate's formatting.
357fn 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
364/// The whole tree minus the top-level keys named in `secrets`.
365///
366/// Top-level is not a limitation here but a property of the source:
367/// `#[config(secret)]` marks fields of the annotated struct, and those fields
368/// ARE the section's top-level keys. The names arrive serde-resolved — a
369/// `#[serde(rename = "pass")]` secret is redacted under `pass`, the key the
370/// resolved tree actually uses.
371/// The document, minus every secret the type declared.
372///
373/// A secret is named by a plain field — what `#[config(secret)]` on a
374/// struct field produces — or by a dotted path into a nested table, which
375/// is what a language binding derives from a nested model. Both mean the
376/// same thing here: the value at that path does not reach the disk, and
377/// neither does anything under it.
378fn 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
388/// Removes the value at a dotted `path`, if the path leads anywhere.
389fn 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
402/// The snapshot's tree, as a document.
403fn as_document(snapshot: &Snapshot) -> Document {
404    match snapshot.to_value() {
405        Value::Table(table) => table,
406        // A snapshot is a table by construction — it is the resolved section,
407        // and a section is keys.
408        _ => Document::new(),
409    }
410}
411
412/// A hash of the values, plus the key names — and no value anywhere.
413fn 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    // Named before the generic "unsupported" because the mistake is specific:
431    // the cache writes plaintext, and a `.age` name would promise otherwise.
432    // Failing loudly here beats a file that says "encrypted" and is not.
433    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/// A map, for the tests below.
450#[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    // The tree these tests build is the resolved one, not the document the
463    // writers take — the explicit import shadows the glob's so `Value` means
464    // the same thing here as it does everywhere a snapshot is held.
465    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        // The key names are there; the values are not.
530        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    /// The document the hard-coded fingerprint below is taken over. Fixed
553    /// on purpose: every scalar shape the tree can hold, nested once.
554    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    /// The value in this assertion is not derived from anything: it is
569    /// written down so that *changing the algorithm is loud*. A fingerprint
570    /// is a cache file's identity, and a silent change to it means every
571    /// cache on disk stops being recognised — the conservative direction,
572    /// but not one to discover from a support ticket. If this fails, the
573    /// fingerprint moved: decide whether that was intended, say so in the
574    /// changelog under Changed, and write the new number down. `std`'s
575    /// hasher changing under us would land here too, which is the point —
576    /// it invalidates caches exactly the same way a change here does.
577    #[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    /// The width a provider chose is not part of the document, so it must
591    /// not be part of its identity: a cache written when `max` arrived as a
592    /// `u16` has to still be recognised when it arrives as a `u64`.
593    #[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    /// The whole round trip the fingerprint mode exists for: written to
610    /// disk, read back, and compared against the same sources.
611    #[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}