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) | 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/// Where the fingerprint lives inside a `Fingerprint` document.
57const FINGERPRINT: &str = "fingerprint";
58
59/// Where the key list lives inside a `Fingerprint` document.
60const KEYS: &str = "keys";
61
62/// How much of a configuration to keep on disk.
63///
64/// A resolved configuration holds every value, including the ones
65/// `#[config(secret)]` exists to keep out of logs. There is no way to make that
66/// not a trade-off, so it is a choice with three answers rather than a default
67/// nobody was told about:
68///
69/// | Mode | On disk | Recovers |
70/// |---|---|---|
71/// | [`Full`](Self::Full) *(default)* | everything, secrets included | completely |
72/// | [`Redacted`](Self::Redacted) | everything except `#[config(secret)]` fields | only if the secrets come from somewhere live |
73/// | [`Fingerprint`](Self::Fingerprint) | a hash and the key names | never — it reports what changed and still fails |
74///
75/// On Unix the file is written `0600`. That is the most that can be done
76/// without refusing the request.
77///
78/// Recovery reads no files: the files are what broke, so it loads from the
79/// cache plus the environment and the runtime layers, never from the sources
80/// whose failure caused it.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82#[non_exhaustive]
83pub enum CacheMode {
84    /// Everything, secrets included. Recovers completely.
85    ///
86    /// The default, because a cache that cannot recover is a cache that will
87    /// disappoint somebody at three in the morning. The file is `0600`; the
88    /// rest is documented rather than solved.
89    #[default]
90    Full,
91    /// Everything except the fields marked `#[config(secret)]`.
92    ///
93    /// Recovery then depends on those values arriving from somewhere live —
94    /// the environment, usually. That is arguably the right deployment shape
95    /// anyway, and useless for anyone whose secrets live in a file.
96    Redacted,
97    /// A hash and the key names. No values at all.
98    ///
99    /// Cannot recover, and does not pretend to: a failed start still fails.
100    /// What it buys is the diagnosis — *which keys have moved since the last
101    /// time this worked* — which is usually the first thing anyone wants.
102    Fingerprint,
103}
104
105impl fmt::Display for CacheMode {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(match self {
108            Self::Full => "full",
109            Self::Redacted => "redacted",
110            Self::Fingerprint => "fingerprint",
111        })
112    }
113}
114
115impl CacheMode {
116    /// Parses the `cache_mode` argument. Unknown names are a compile error, so
117    /// this is only reached with something the macro already accepted.
118    pub(crate) fn parse(name: &str) -> Option<Self> {
119        match name {
120            "full" => Some(Self::Full),
121            "redacted" => Some(Self::Redacted),
122            "fingerprint" => Some(Self::Fingerprint),
123            _ => None,
124        }
125    }
126
127    /// Whether a cache in this mode can stand in for the real thing.
128    #[must_use]
129    pub fn recovers(self) -> bool {
130        !matches!(self, Self::Fingerprint)
131    }
132}
133
134/// What a cache file turned out to hold.
135#[derive(Debug)]
136pub enum Recovery {
137    /// A configuration to start from.
138    Usable(Snapshot),
139    /// Only a fingerprint: the keys that differ from the last good state.
140    ///
141    /// Empty when the keys match and only a value moved.
142    Drift(Vec<String>),
143    /// No cache on disk yet.
144    Absent,
145}
146
147/// Writes `snapshot` to `path` in `mode`.
148///
149/// `secrets` are the field names to drop in [`CacheMode::Redacted`]; ignored
150/// otherwise.
151///
152/// # Errors
153///
154/// If the path names no supported format, or the file cannot be written.
155pub(crate) fn write(
156    snapshot: &Snapshot,
157    path: &Path,
158    mode: CacheMode,
159    secrets: &[&str],
160) -> Result<(), Error> {
161    let format = format_of(path)?;
162
163    let document = match mode {
164        CacheMode::Full => snapshot.values().clone(),
165        CacheMode::Redacted => without(snapshot.values(), secrets),
166        CacheMode::Fingerprint => fingerprint_document(snapshot),
167    };
168
169    crate::write::save_dict(&document, path, format, CACHED)
170}
171
172/// Reads whatever `path` holds.
173///
174/// # Errors
175///
176/// If the file exists but cannot be read or parsed. A file that is not there is
177/// [`Recovery::Absent`], not a failure — the first start has no cache.
178pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
179    let format = format_of(path)?;
180
181    let text = match std::fs::read_to_string(path) {
182        Ok(text) => text,
183        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
184        Err(error) => {
185            return Err(Error::new(ErrorKind::Io, error.to_string())
186                .with_origin(Origin::File(path.to_owned())))
187        }
188    };
189
190    let sources = [crate::Source::inline(&text, format)];
191    let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
192
193    if !cached.contains(FINGERPRINT) {
194        return Ok(Recovery::Usable(cached));
195    }
196
197    Ok(Recovery::Drift(drift(&cached, current)))
198}
199
200/// Which keys have appeared or vanished since the cache was written.
201fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Vec<String> {
202    let Some(current) = current else {
203        return Vec::new();
204    };
205
206    let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
207    let after = current.leaf_paths();
208
209    let mut moved: Vec<String> = before
210        .iter()
211        .filter(|key| !after.contains(key))
212        .map(|key| format!("{key} is gone"))
213        .chain(
214            after
215                .iter()
216                .filter(|key| !before.contains(key))
217                .map(|key| format!("{key} is new")),
218        )
219        .collect();
220
221    moved.sort();
222    moved
223}
224
225/// The whole tree minus the top-level keys named in `secrets`.
226fn without(values: &Dict, secrets: &[&str]) -> Dict {
227    values
228        .iter()
229        .filter(|(key, _)| !secrets.contains(&key.as_str()))
230        .map(|(key, value)| (key.clone(), value.clone()))
231        .collect()
232}
233
234/// A hash of the values, plus the key names — and no value anywhere.
235fn fingerprint_document(snapshot: &Snapshot) -> Dict {
236    let keys = snapshot.leaf_paths();
237
238    let mut hasher = DefaultHasher::new();
239
240    // `Debug` rather than `Hash`: figment's values carry a provenance tag that
241    // takes part in equality, and two identical values from different providers
242    // must fingerprint the same.
243    format!("{:?}", snapshot.values()).hash(&mut hasher);
244
245    let mut document = Dict::new();
246    document.insert(
247        FINGERPRINT.to_owned(),
248        Value::from(format!("{:016x}", hasher.finish())),
249    );
250    document.insert(
251        KEYS.to_owned(),
252        Value::from(keys.into_iter().map(Value::from).collect::<Vec<_>>()),
253    );
254
255    document
256}
257
258fn format_of(path: &Path) -> Result<Format, Error> {
259    // Named before the generic "unsupported" because the mistake is specific:
260    // the cache writes plaintext, and a `.age` name would promise otherwise.
261    // Failing loudly here beats a file that says "encrypted" and is not.
262    if path.extension().is_some_and(|extension| extension == "age") {
263        return Err(Error::new(
264            ErrorKind::Backend,
265            format!(
266                "{} ends in `.age`, but the last-known-good cache is written                  in plaintext; give the cache an unencrypted name",
267                path.display()
268            ),
269        ));
270    }
271
272    path.extension()
273        .and_then(|extension| extension.to_str())
274        .and_then(Format::from_extension)
275        .ok_or_else(|| Error::unsupported(path))
276}
277
278/// A map, for the tests below.
279#[cfg(all(test, feature = "json"))]
280fn dict_of(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
281    entries
282        .iter()
283        .map(|(key, value)| ((*key).to_owned(), value.clone()))
284        .collect()
285}
286
287#[cfg(all(test, feature = "json"))]
288mod tests {
289    use super::*;
290
291    fn scratch(test: &str) -> std::path::PathBuf {
292        let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
293
294        let _ = std::fs::remove_dir_all(&directory);
295        std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
296
297        directory.join("cache.json")
298    }
299
300    fn snapshot() -> Snapshot {
301        Snapshot::new(dict_of(&[
302            ("host", "localhost".into()),
303            ("password", "hunter2".into()),
304            ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
305        ]))
306    }
307
308    #[test]
309    fn full_keeps_everything_and_recovers() {
310        let path = scratch("full");
311
312        write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
313
314        let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
315            panic!("a full cache must be usable");
316        };
317
318        assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
319        assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
320        assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
321    }
322
323    #[test]
324    fn redacted_drops_the_marked_fields_and_nothing_else() {
325        let path = scratch("redacted");
326
327        write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
328
329        let written = std::fs::read_to_string(&path).unwrap();
330        assert!(!written.contains("hunter2"), "{written}");
331
332        let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
333            panic!("a redacted cache is still usable");
334        };
335
336        assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
337        assert!(
338            !recovered.contains("password"),
339            "the secret must not have survived"
340        );
341    }
342
343    #[test]
344    fn fingerprint_writes_no_value_at_all() {
345        let path = scratch("fingerprint");
346
347        write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
348
349        let written = std::fs::read_to_string(&path).unwrap();
350
351        assert!(!written.contains("hunter2"), "{written}");
352        assert!(!written.contains("localhost"), "{written}");
353        // The key names are there; the values are not.
354        assert!(written.contains("host"), "{written}");
355    }
356
357    #[test]
358    fn fingerprint_cannot_recover_but_reports_what_moved() {
359        let path = scratch("drift");
360
361        write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
362
363        let now = Snapshot::new(dict_of(&[
364            ("host", "localhost".into()),
365            ("hsot", "typo".into()),
366        ]));
367
368        let Recovery::Drift(moved) = read(&path, Some(&now)).unwrap() else {
369            panic!("a fingerprint cache cannot be usable");
370        };
371
372        assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
373        assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
374    }
375
376    #[test]
377    fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
378        let error = write(
379            &snapshot(),
380            Path::new("cache.json.age"),
381            CacheMode::Full,
382            &[],
383        )
384        .unwrap_err();
385
386        assert!(error.to_string().contains("plaintext"), "{error}");
387    }
388
389    #[test]
390    fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
391        let path = scratch("absent").with_file_name("nothing.json");
392
393        assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
394    }
395
396    #[test]
397    fn only_fingerprint_refuses_to_recover() {
398        assert!(CacheMode::Full.recovers());
399        assert!(CacheMode::Redacted.recovers());
400        assert!(!CacheMode::Fingerprint.recovers());
401    }
402}