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/// 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) *(default)* | everything, secrets included | completely |
82/// | [`Redacted`](Self::Redacted) | 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 /// Parses the `cache_mode` argument. Unknown names are a compile error, so
127 /// this is only reached with something the macro already accepted.
128 pub(crate) fn parse(name: &str) -> Option<Self> {
129 match name {
130 "full" => Some(Self::Full),
131 "redacted" => Some(Self::Redacted),
132 "fingerprint" => Some(Self::Fingerprint),
133 _ => None,
134 }
135 }
136
137 /// Whether a cache in this mode can stand in for the real thing.
138 #[must_use]
139 pub fn recovers(self) -> bool {
140 !matches!(self, Self::Fingerprint)
141 }
142}
143
144/// What a cache file turned out to hold.
145#[derive(Debug)]
146pub enum Recovery {
147 /// A configuration to start from.
148 Usable(Snapshot),
149 /// Only a fingerprint: what differs from the last good state.
150 ///
151 /// `Some` lists the key paths that differ — or one explanatory sentence
152 /// when the keys match and only values moved. `None` means the comparison
153 /// itself was impossible: the sources do not resolve, so there is nothing
154 /// to compare against.
155 Drift(Option<Vec<String>>),
156 /// No cache on disk yet.
157 Absent,
158}
159
160/// Writes `snapshot` to `path` in `mode`.
161///
162/// `secrets` are the field names to drop in [`CacheMode::Redacted`]; ignored
163/// otherwise.
164///
165/// # Errors
166///
167/// If the path names no supported format, or the file cannot be written.
168pub(crate) fn write(
169 snapshot: &Snapshot,
170 path: &Path,
171 mode: CacheMode,
172 secrets: &[&str],
173) -> Result<(), Error> {
174 let format = format_of(path)?;
175
176 let mut document = match mode {
177 CacheMode::Full => snapshot.values().clone(),
178 CacheMode::Redacted => without(snapshot.values(), secrets),
179 CacheMode::Fingerprint => fingerprint_document(snapshot),
180 };
181
182 let mut marker = Dict::new();
183 marker.insert("version".to_owned(), Value::from(1));
184 marker.insert(
185 "mode".to_owned(),
186 Value::from(match mode {
187 CacheMode::Full => "full",
188 CacheMode::Redacted => "redacted",
189 CacheMode::Fingerprint => "fingerprint",
190 }),
191 );
192 document.insert(MARKER.to_owned(), Value::from(marker));
193
194 crate::write::save_dict(&document, path, format, CACHED)
195}
196
197/// Reads whatever `path` holds.
198///
199/// # Errors
200///
201/// If the file exists but cannot be read or parsed. A file that is not there is
202/// [`Recovery::Absent`], not a failure — the first start has no cache.
203pub(crate) fn read(path: &Path, current: Option<&Snapshot>) -> Result<Recovery, Error> {
204 let format = format_of(path)?;
205
206 let text = match std::fs::read_to_string(path) {
207 Ok(text) => text,
208 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Recovery::Absent),
209 Err(error) => {
210 return Err(Error::new(ErrorKind::Io, error.to_string())
211 .with_origin(Origin::File(path.to_owned())))
212 }
213 };
214
215 let sources = [crate::Source::inline(&text, format)];
216 let cached = crate::loader::snapshot(&crate::LoadSpec::new(CACHED, &sources))?;
217
218 // The marker says what the document is. Files written before the marker
219 // existed (0.0.1) fall back to the old heuristic for one release —
220 // documented in the changelog, removed after it.
221 let is_fingerprint = match cached.get::<String>(&format!("{MARKER}.mode")) {
222 Ok(mode) => mode == "fingerprint",
223 Err(_) => cached.contains(FINGERPRINT),
224 };
225
226 if !is_fingerprint {
227 return Ok(Recovery::Usable(cached.without_top_level(MARKER)));
228 }
229
230 Ok(Recovery::Drift(drift(&cached, current)))
231}
232
233/// Which keys have appeared or vanished since the cache was written.
234///
235/// `None` means "could not compare": the sources did not resolve — which is
236/// the ordinary case during recovery, since a broken source is *why*
237/// recovery is running. The caller must say so rather than claim a
238/// comparison that never happened.
239fn drift(cached: &Snapshot, current: Option<&Snapshot>) -> Option<Vec<String>> {
240 let current = current?;
241
242 let before: Vec<String> = cached.get(KEYS).unwrap_or_default();
243 let after = current.leaf_paths();
244
245 let mut moved: Vec<String> = before
246 .iter()
247 .filter(|key| !after.contains(key))
248 .map(|key| format!("{key} is gone"))
249 .chain(
250 after
251 .iter()
252 .filter(|key| !before.contains(key))
253 .map(|key| format!("{key} is new")),
254 )
255 .collect();
256
257 moved.sort();
258
259 // The keys all match — that is what the stored hash is FOR: telling
260 // "identical" apart from "same keys, different values". It used to be
261 // written and never read, and the report asserted the comparison anyway.
262 if moved.is_empty() {
263 let stored: Option<String> = cached.get(FINGERPRINT).ok();
264 let current_hash = fingerprint_of(current);
265
266 if stored.as_deref() == Some(current_hash.as_str()) {
267 return Some(vec!["nothing moved — the sources match the last good \
268 configuration exactly"
269 .to_owned()]);
270 }
271
272 return Some(vec!["the same keys, with different values".to_owned()]);
273 }
274
275 Some(moved)
276}
277
278/// The hash of a snapshot's values, as `fingerprint_document` computes it.
279fn fingerprint_of(snapshot: &Snapshot) -> String {
280 let mut hasher = DefaultHasher::new();
281 format!("{:?}", snapshot.values()).hash(&mut hasher);
282
283 format!("{:016x}", hasher.finish())
284}
285
286/// The whole tree minus the top-level keys named in `secrets`.
287///
288/// Top-level is not a limitation here but a property of the source:
289/// `#[config(secret)]` marks fields of the annotated struct, and those fields
290/// ARE the section's top-level keys. The names arrive serde-resolved — a
291/// `#[serde(rename = "pass")]` secret is redacted under `pass`, the key the
292/// resolved tree actually uses.
293fn without(values: &Dict, secrets: &[&str]) -> Dict {
294 values
295 .iter()
296 .filter(|(key, _)| !secrets.contains(&key.as_str()))
297 .map(|(key, value)| (key.clone(), value.clone()))
298 .collect()
299}
300
301/// A hash of the values, plus the key names — and no value anywhere.
302fn fingerprint_document(snapshot: &Snapshot) -> Dict {
303 let keys = snapshot.leaf_paths();
304
305 // `Debug` rather than `Hash` (inside `fingerprint_of`): figment's values
306 // carry a provenance tag that takes part in equality, and two identical
307 // values from different providers must fingerprint the same.
308 let mut document = Dict::new();
309 document.insert(
310 FINGERPRINT.to_owned(),
311 Value::from(fingerprint_of(snapshot)),
312 );
313 document.insert(
314 KEYS.to_owned(),
315 Value::from(keys.into_iter().map(Value::from).collect::<Vec<_>>()),
316 );
317
318 document
319}
320
321fn format_of(path: &Path) -> Result<Format, Error> {
322 // Named before the generic "unsupported" because the mistake is specific:
323 // the cache writes plaintext, and a `.age` name would promise otherwise.
324 // Failing loudly here beats a file that says "encrypted" and is not.
325 if path.extension().is_some_and(|extension| extension == "age") {
326 return Err(Error::new(
327 ErrorKind::Backend,
328 format!(
329 "{} ends in `.age`, but the last-known-good cache is written in plaintext; give the cache an unencrypted name",
330 path.display()
331 ),
332 ));
333 }
334
335 path.extension()
336 .and_then(|extension| extension.to_str())
337 .and_then(Format::from_extension)
338 .ok_or_else(|| Error::unsupported(path))
339}
340
341/// A map, for the tests below.
342#[cfg(all(test, feature = "json"))]
343fn dict_of(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
344 entries
345 .iter()
346 .map(|(key, value)| ((*key).to_owned(), value.clone()))
347 .collect()
348}
349
350#[cfg(all(test, feature = "json"))]
351mod tests {
352 use super::*;
353
354 fn scratch(test: &str) -> std::path::PathBuf {
355 let directory = std::env::temp_dir().join("dynamic-config-cache").join(test);
356
357 let _ = std::fs::remove_dir_all(&directory);
358 std::fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
359
360 directory.join("cache.json")
361 }
362
363 fn snapshot() -> Snapshot {
364 Snapshot::new(dict_of(&[
365 ("host", "localhost".into()),
366 ("password", "hunter2".into()),
367 ("pool", Value::from(dict_of(&[("max", 10u16.into())]))),
368 ]))
369 }
370
371 #[test]
372 fn full_keeps_everything_and_recovers() {
373 let path = scratch("full");
374
375 write(&snapshot(), &path, CacheMode::Full, &["password"]).unwrap();
376
377 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
378 panic!("a full cache must be usable");
379 };
380
381 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
382 assert_eq!(recovered.get::<String>("password").unwrap(), "hunter2");
383 assert_eq!(recovered.get::<u16>("pool.max").unwrap(), 10);
384 }
385
386 #[test]
387 fn redacted_drops_the_marked_fields_and_nothing_else() {
388 let path = scratch("redacted");
389
390 write(&snapshot(), &path, CacheMode::Redacted, &["password"]).unwrap();
391
392 let written = std::fs::read_to_string(&path).unwrap();
393 assert!(!written.contains("hunter2"), "{written}");
394
395 let Recovery::Usable(recovered) = read(&path, None).unwrap() else {
396 panic!("a redacted cache is still usable");
397 };
398
399 assert_eq!(recovered.get::<String>("host").unwrap(), "localhost");
400 assert!(
401 !recovered.contains("password"),
402 "the secret must not have survived"
403 );
404 }
405
406 #[test]
407 fn fingerprint_writes_no_value_at_all() {
408 let path = scratch("fingerprint");
409
410 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
411
412 let written = std::fs::read_to_string(&path).unwrap();
413
414 assert!(!written.contains("hunter2"), "{written}");
415 assert!(!written.contains("localhost"), "{written}");
416 // The key names are there; the values are not.
417 assert!(written.contains("host"), "{written}");
418 }
419
420 #[test]
421 fn fingerprint_cannot_recover_but_reports_what_moved() {
422 let path = scratch("drift");
423
424 write(&snapshot(), &path, CacheMode::Fingerprint, &[]).unwrap();
425
426 let now = Snapshot::new(dict_of(&[
427 ("host", "localhost".into()),
428 ("hsot", "typo".into()),
429 ]));
430
431 let Recovery::Drift(Some(moved)) = read(&path, Some(&now)).unwrap() else {
432 panic!("a fingerprint cache cannot be usable");
433 };
434
435 assert!(moved.contains(&"hsot is new".to_owned()), "{moved:?}");
436 assert!(moved.contains(&"password is gone".to_owned()), "{moved:?}");
437 }
438
439 #[test]
440 fn an_age_cache_path_is_refused_because_the_cache_is_plaintext() {
441 let error = write(
442 &snapshot(),
443 Path::new("cache.json.age"),
444 CacheMode::Full,
445 &[],
446 )
447 .unwrap_err();
448
449 assert!(error.to_string().contains("plaintext"), "{error}");
450 }
451
452 #[test]
453 fn a_first_start_has_no_cache_and_that_is_not_a_failure() {
454 let path = scratch("absent").with_file_name("nothing.json");
455
456 assert!(matches!(read(&path, None).unwrap(), Recovery::Absent));
457 }
458
459 #[test]
460 fn only_fingerprint_refuses_to_recover() {
461 assert!(CacheMode::Full.recovers());
462 assert!(CacheMode::Redacted.recovers());
463 assert!(!CacheMode::Fingerprint.recovers());
464 }
465}