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
37#[cfg(all(test, feature = "json"))]
38use std::collections::BTreeMap;
39use std::fmt;
40// `std::collections::hash_map::DefaultHasher` rather than `std::hash::`: the
41// latter is the same type re-exported, but only since 1.76, and the core
42// crate's floor is 1.71.
43use 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
53/// The key a cache document is written under, so it reads back like any file.
54const CACHED: &str = "cached";
55
56/// The marker every cache document carries, naming what it is.
57///
58/// The reader used to *sniff*: "has a top-level `fingerprint` key" meant
59/// "is a fingerprint document" — and a real configuration with a
60/// `fingerprint` section (a TLS pin, an image digest) was misread as one,
61/// turning a perfectly good full cache into a refusal to start. A document
62/// that says what it is cannot be misread. `version` is there so a future
63/// format change can tell old files from new ones.
64const MARKER: &str = "__dynamic_config_cache";
65
66/// Where the fingerprint lives inside a `Fingerprint` document.
67const FINGERPRINT: &str = "fingerprint";
68
69/// Where the key list lives inside a `Fingerprint` document.
70const KEYS: &str = "keys";
71
72/// How much of a configuration to keep on disk.
73///
74/// A resolved configuration holds every value, including the ones
75/// `#[config(secret)]` exists to keep out of logs. There is no way to make that
76/// not a trade-off, so it is a choice with three answers rather than a default
77/// nobody was told about:
78///
79/// | Mode | On disk | Recovers |
80/// |---|---|---|
81/// | [`Full`](Self::Full) | everything, secrets included | completely |
82/// | [`Redacted`](Self::Redacted) *(the attribute's default)* | everything except `#[config(secret)]` fields | only if the secrets come from somewhere live |
83/// | [`Fingerprint`](Self::Fingerprint) | a hash and the key names | never — it reports what changed and still fails |
84///
85/// On Unix the file is written `0600`. That is the most that can be done
86/// without refusing the request.
87///
88/// Recovery reads no files: the files are what broke, so it loads from the
89/// cache plus the environment and the runtime layers, never from the sources
90/// whose failure caused it.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92#[non_exhaustive]
93pub enum CacheMode {
94    /// Everything, secrets included. Recovers completely.
95    ///
96    /// The default, because a cache that cannot recover is a cache that will
97    /// disappoint somebody at three in the morning. The file is `0600`; the
98    /// rest is documented rather than solved.
99    #[default]
100    Full,
101    /// Everything except the fields marked `#[config(secret)]`.
102    ///
103    /// Recovery then depends on those values arriving from somewhere live —
104    /// the environment, usually. That is arguably the right deployment shape
105    /// anyway, and useless for anyone whose secrets live in a file.
106    Redacted,
107    /// A hash and the key names. No values at all.
108    ///
109    /// Cannot recover, and does not pretend to: a failed start still fails.
110    /// What it buys is the diagnosis — *which keys have moved since the last
111    /// time this worked* — which is usually the first thing anyone wants.
112    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    /// Whether a cache in this mode can stand in for the real thing.
127    #[must_use]
128    pub fn recovers(self) -> bool {
129        !matches!(self, Self::Fingerprint)
130    }
131}
132
133/// What a cache file turned out to hold.
134#[derive(Debug)]
135pub enum Recovery {
136    /// A configuration to start from.
137    Usable(Snapshot),
138    /// Only a fingerprint: what differs from the last good state.
139    ///
140    /// `Some` lists the key paths that differ — or one explanatory sentence
141    /// when the keys match and only values moved. `None` means the comparison
142    /// itself was impossible: the sources do not resolve, so there is nothing
143    /// to compare against.
144    Drift(Option<Vec<String>>),
145    /// No cache on disk yet.
146    Absent,
147}
148
149/// Writes `snapshot` to `path` in `mode`.
150///
151/// `secrets` are the field names to drop in [`CacheMode::Redacted`]; ignored
152/// otherwise.
153///
154/// # Errors
155///
156/// If the path names no supported format, or the file cannot be written.
157pub(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/// [`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 = 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/// [`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.
352fn 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
359/// The whole tree minus the top-level keys named in `secrets`.
360///
361/// Top-level is not a limitation here but a property of the source:
362/// `#[config(secret)]` marks fields of the annotated struct, and those fields
363/// ARE the section's top-level keys. The names arrive serde-resolved — a
364/// `#[serde(rename = "pass")]` secret is redacted under `pass`, the key the
365/// resolved tree actually uses.
366/// The document, minus every secret the type declared.
367///
368/// A secret is named by a plain field — what `#[config(secret)]` on a
369/// struct field produces — or by a dotted path into a nested table, which
370/// is what a language binding derives from a nested model. Both mean the
371/// same thing here: the value at that path does not reach the disk, and
372/// neither does anything under it.
373fn 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
383/// Removes the value at a dotted `path`, if the path leads anywhere.
384fn 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
397/// A hash of the values, plus the key names — and no value anywhere.
398fn 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    // Named before the generic "unsupported" because the mistake is specific:
416    // the cache writes plaintext, and a `.age` name would promise otherwise.
417    // Failing loudly here beats a file that says "encrypted" and is not.
418    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/// A map, for the tests below.
435#[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        // The key names are there; the values are not.
510        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    /// The document the hard-coded fingerprint below is taken over. Fixed
533    /// on purpose: every scalar shape the tree can hold, nested once.
534    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    /// The value in this assertion is not derived from anything: it is
549    /// written down so that *changing the algorithm is loud*. A fingerprint
550    /// is a cache file's identity, and a silent change to it means every
551    /// cache on disk stops being recognised — the conservative direction,
552    /// but not one to discover from a support ticket. If this fails, the
553    /// fingerprint moved: decide whether that was intended, say so in the
554    /// changelog under Changed, and write the new number down. `std`'s
555    /// hasher changing under us would land here too, which is the point —
556    /// it invalidates caches exactly the same way a change here does.
557    #[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    /// The width a provider chose is not part of the document, so it must
571    /// not be part of its identity: a cache written when `max` arrived as a
572    /// `u16` has to still be recognised when it arrives as a `u64`.
573    #[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    /// The whole round trip the fingerprint mode exists for: written to
590    /// disk, read back, and compared against the same sources.
591    #[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}