Skip to main content

dynamic_config/
snapshot.rs

1//! Comparing one resolved configuration against another.
2//!
3//! "Configuration reloaded" is a nearly useless log line during an incident:
4//! the question is always *what* changed. A snapshot is the resolved section
5//! before it becomes a struct, so two of them can be compared key by key.
6//!
7//! **Only paths are reported, never values.** That keeps a reload of
8//! `db.password` from doing in the log exactly what `#[config(secret)]` exists
9//! to prevent. Code that needs the values already has both sides in an
10//! `on_reload` callback.
11
12use std::collections::BTreeMap;
13use std::fmt;
14
15use figment::value::{Dict, Value};
16use serde::de::DeserializeOwned;
17
18use crate::error::{Error, ErrorKind, Origin};
19
20/// What happened to one key between two snapshots.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum ChangeKind {
24    /// Nothing supplied it before.
25    Added,
26    /// Nothing supplies it any more.
27    Removed,
28    /// It has a different value.
29    Modified,
30}
31
32impl fmt::Display for ChangeKind {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(match self {
35            Self::Added => "added",
36            Self::Removed => "removed",
37            Self::Modified => "changed",
38        })
39    }
40}
41
42/// One difference between two snapshots.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Change {
45    /// Dotted key path, relative to the section.
46    pub path: String,
47    /// What happened to it.
48    pub kind: ChangeKind,
49}
50
51impl fmt::Display for Change {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{} {}", self.path, self.kind)
54    }
55}
56
57/// A resolved configuration section, before it becomes a struct.
58///
59/// Obtained from [`snapshot`](crate::snapshot), compared with
60/// [`diff`](Self::diff), and turned into a struct with
61/// [`extract`](Self::extract).
62#[derive(Clone, Default)]
63pub struct Snapshot {
64    values: Dict,
65    /// Where each leaf came from, captured while the figment that knew was
66    /// still alive. Empty for a snapshot that was not produced by a live
67    /// resolution — one read back from the cache, for instance.
68    provenance: BTreeMap<String, Origin>,
69}
70
71impl Snapshot {
72    pub(crate) fn new(values: Dict) -> Self {
73        Self {
74            values,
75            provenance: BTreeMap::new(),
76        }
77    }
78
79    /// Attaches where each leaf came from, at resolution time.
80    pub(crate) fn attach_provenance(&mut self, provenance: BTreeMap<String, Origin>) {
81        self.provenance = provenance;
82    }
83
84    /// Where the value at `path` in **this snapshot** came from.
85    ///
86    /// This answers for the snapshot in hand — the values that were actually
87    /// resolved together. The free-standing
88    /// [`source_of`](crate::source_of) answers a different question: what the
89    /// *next* load would see, re-reading the sources now.
90    ///
91    /// `None` when nothing supplies `path`, and for snapshots that did not
92    /// come from a live resolution (a cache read, a [`sub`](Self::sub) of
93    /// one of those): provenance is captured at resolution time and cannot
94    /// be reconstructed later.
95    #[must_use]
96    pub fn source_of(&self, path: &str) -> Option<&Origin> {
97        self.provenance.get(path)
98    }
99
100    /// Deserializes the section into `T`.
101    ///
102    /// # Errors
103    ///
104    /// If a required value is missing or cannot become the field's type.
105    ///
106    /// Errors here carry the key path but **not** the originating file or
107    /// variable: provenance lives in figment's metadata, which a snapshot has
108    /// already left behind. Call [`load`](crate::load) when the error is going
109    /// to be read by a person.
110    pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
111        Value::from(self.values.clone())
112            .deserialize()
113            .map_err(|error: figment::Error| {
114                let mut translated = Error::new(ErrorKind::Type, error.to_string());
115
116                for segment in error.path.iter().rev() {
117                    translated = translated.prepend_key(segment);
118                }
119
120                translated
121            })
122    }
123
124    /// Every key that differs between `self` and `other`, in path order.
125    ///
126    /// `self` is the earlier snapshot, so a key present only in `other` is
127    /// [`Added`](ChangeKind::Added).
128    #[must_use]
129    pub fn diff(&self, other: &Self) -> Vec<Change> {
130        let mut changes = Vec::new();
131
132        compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
133        changes.sort_by(|left, right| left.path.cmp(&right.path));
134
135        changes
136    }
137
138    /// Reads one value by dotted path, without a struct to hold it.
139    ///
140    /// For the shape a program does not know at compile time: a plugin's
141    /// section, a user-defined table. Everything else should go through a
142    /// struct, where a typo is a compile error rather than a runtime one.
143    ///
144    /// # Errors
145    ///
146    /// If nothing supplies `path`, or the value cannot become `T`.
147    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
148        let value = self.at(path).ok_or_else(|| {
149            Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
150        })?;
151
152        value.deserialize().map_err(|error: figment::Error| {
153            Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
154        })
155    }
156
157    /// This snapshot minus one top-level key — how the cache strips its own
158    /// marker before the values are handed back as configuration.
159    pub(crate) fn without_top_level(&self, key: &str) -> Self {
160        let mut values = self.values().clone();
161        values.remove(key);
162
163        let prefix = format!("{key}.");
164        let provenance = self
165            .provenance
166            .iter()
167            .filter(|(path, _)| *path != key && !path.starts_with(&prefix))
168            .map(|(path, origin)| (path.clone(), origin.clone()))
169            .collect();
170
171        Self { values, provenance }
172    }
173
174    /// Whether anything supplies `path`.
175    #[must_use]
176    pub fn contains(&self, path: &str) -> bool {
177        self.at(path).is_some()
178    }
179
180    /// The table at `path`, as a snapshot of its own.
181    ///
182    /// The analogue of Viper's `Sub`: hand a subsystem the part of the
183    /// configuration it owns and nothing else. Provenance follows: the sub-
184    /// snapshot's [`source_of`](Self::source_of) answers for its own,
185    /// re-rooted paths.
186    #[must_use]
187    pub fn sub(&self, path: &str) -> Option<Self> {
188        match self.at(path)? {
189            Value::Dict(_, nested) => {
190                let prefix = format!("{path}.");
191                let provenance = self
192                    .provenance
193                    .iter()
194                    .filter_map(|(leaf, origin)| {
195                        leaf.strip_prefix(&prefix)
196                            .map(|rest| (rest.to_owned(), origin.clone()))
197                    })
198                    .collect();
199
200                Some(Self {
201                    values: nested.clone(),
202                    provenance,
203                })
204            }
205            _ => None,
206        }
207    }
208
209    fn at(&self, path: &str) -> Option<&Value> {
210        let mut segments = path.split('.');
211        let mut current = self.values.get(segments.next()?)?;
212
213        for segment in segments {
214            let Value::Dict(_, nested) = current else {
215                return None;
216            };
217
218            current = nested.get(segment)?;
219        }
220
221        Some(current)
222    }
223
224    /// The dotted path of every leaf, in order.
225    #[must_use]
226    pub fn leaf_paths(&self) -> Vec<String> {
227        let mut paths = Vec::new();
228
229        collect_leaves(&self.values, &mut Vec::new(), &mut paths);
230
231        paths
232    }
233
234    /// The section's immediate keys — the level a struct's fields map onto.
235    #[must_use]
236    pub fn top_level_keys(&self) -> Vec<String> {
237        self.values.keys().cloned().collect()
238    }
239
240    /// The resolved tree, for the few places that need it whole.
241    pub(crate) fn values(&self) -> &Dict {
242        &self.values
243    }
244
245    /// Whether the section resolved to nothing at all.
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.values.is_empty()
249    }
250}
251
252/// Keys and shape only, never values: a snapshot holds the *resolved*
253/// configuration, secrets included, and `{:?}` in a log line is exactly how
254/// resolved secrets leak. The dropped `#[derive(Debug)]` is the mistake
255/// AGENTS.md warns about, made by this crate itself.
256impl fmt::Debug for Snapshot {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_struct("Snapshot")
259            .field("keys", &self.top_level_keys())
260            .field("leaves", &self.leaf_paths().len())
261            .field("provenance", &self.provenance.len())
262            .finish_non_exhaustive()
263    }
264}
265
266/// The dotted paths that differ between two configuration values.
267///
268/// The audit half of a reload hook: `on_reload` hands over both structs, and
269/// this names what moved — paths only, never values, same as every other
270/// diagnostic here.
271///
272/// ```
273/// # use serde::Serialize;
274/// #[derive(Serialize)]
275/// struct Db { host: String, port: u16 }
276///
277/// let before = Db { host: "a".into(), port: 1 };
278/// let after = Db { host: "a".into(), port: 2 };
279///
280/// let changes = dynamic_config::changed_paths(&before, &after).unwrap();
281/// assert_eq!(changes.len(), 1);
282/// assert_eq!(changes[0].path, "port");
283/// ```
284///
285/// # Errors
286///
287/// If either value does not serialize to a table — a bare scalar has no
288/// paths to compare.
289pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
290    let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
291        match Value::serialize(value) {
292            Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
293            Ok(_) => Err(Error::new(
294                ErrorKind::Type,
295                "only a table has paths to compare; this serializes to a scalar",
296            )),
297            Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
298        }
299    };
300
301    Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
302}
303
304/// Records the dotted path of every leaf, treating an empty table as one.
305fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
306    for (key, value) in values {
307        path.push(key.clone());
308
309        match value {
310            Value::Dict(_, nested) if !nested.is_empty() => {
311                collect_leaves(nested, path, paths);
312            }
313            _ => paths.push(path.join(".")),
314        }
315
316        path.pop();
317    }
318}
319
320/// Walks two tables in step, recording the leaves that differ.
321fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
322    for (key, before) in previous {
323        path.push(key.clone());
324
325        match current.get(key) {
326            Some(after) => compare_values(before, after, path, changes),
327            None => changes.push(change(path, ChangeKind::Removed)),
328        }
329
330        path.pop();
331    }
332
333    for key in current.keys() {
334        if previous.contains_key(key) {
335            continue;
336        }
337
338        path.push(key.clone());
339        changes.push(change(path, ChangeKind::Added));
340        path.pop();
341    }
342}
343
344fn compare_values(
345    before: &Value,
346    after: &Value,
347    path: &mut Vec<String>,
348    changes: &mut Vec<Change>,
349) {
350    match (before, after) {
351        // Two tables are compared key by key, so a change deep inside one is
352        // reported at the leaf that actually moved rather than at the table.
353        (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
354        _ if values_equal(before, after) => {}
355        _ => changes.push(change(path, ChangeKind::Modified)),
356    }
357}
358
359/// figment values carry a provenance tag that takes part in `PartialEq`, so two
360/// identical values from different providers compare unequal. Rendering strips
361/// the tag, which is the comparison anyone actually means here.
362fn values_equal(before: &Value, after: &Value) -> bool {
363    format!("{before:?}") == format!("{after:?}")
364}
365
366fn change(path: &[String], kind: ChangeKind) -> Change {
367    Change {
368        path: path.join("."),
369        kind,
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn dict(entries: &[(&str, Value)]) -> Dict {
378        entries
379            .iter()
380            .map(|(key, value)| ((*key).to_owned(), value.clone()))
381            .collect()
382    }
383
384    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
385        Snapshot::new(dict(entries))
386    }
387
388    #[test]
389    fn identical_snapshots_have_no_changes() {
390        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
391        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
392
393        assert!(one.diff(&two).is_empty());
394    }
395
396    #[test]
397    fn a_modified_value_names_its_key_but_not_its_value() {
398        let one = snapshot(&[("password", "hunter2".into())]);
399        let two = snapshot(&[("password", "letmein".into())]);
400
401        let changes = one.diff(&two);
402
403        assert_eq!(changes.len(), 1);
404        assert_eq!(changes[0].path, "password");
405        assert_eq!(changes[0].kind, ChangeKind::Modified);
406
407        let rendered = changes[0].to_string();
408        assert_eq!(rendered, "password changed");
409        assert!(!rendered.contains("hunter2"), "{rendered}");
410        assert!(!rendered.contains("letmein"), "{rendered}");
411    }
412
413    #[test]
414    fn additions_and_removals_are_told_apart() {
415        let one = snapshot(&[("gone", 1u16.into())]);
416        let two = snapshot(&[("fresh", 1u16.into())]);
417
418        let changes = one.diff(&two);
419
420        assert_eq!(
421            changes,
422            [
423                Change {
424                    path: "fresh".to_owned(),
425                    kind: ChangeKind::Added,
426                },
427                Change {
428                    path: "gone".to_owned(),
429                    kind: ChangeKind::Removed,
430                },
431            ]
432        );
433    }
434
435    #[test]
436    fn a_change_inside_a_table_is_reported_at_the_leaf() {
437        let one = snapshot(&[(
438            "pool",
439            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
440        )]);
441        let two = snapshot(&[(
442            "pool",
443            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
444        )]);
445
446        let changes = one.diff(&two);
447
448        assert_eq!(changes.len(), 1);
449        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
450    }
451
452    #[test]
453    fn a_table_replaced_by_a_scalar_is_one_change() {
454        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
455        let two = snapshot(&[("pool", 1u16.into())]);
456
457        let changes = one.diff(&two);
458
459        assert_eq!(changes.len(), 1);
460        assert_eq!(changes[0].path, "pool");
461        assert_eq!(changes[0].kind, ChangeKind::Modified);
462    }
463
464    #[test]
465    fn a_value_can_be_read_by_path_without_a_struct() {
466        let snapshot = snapshot(&[
467            ("host", "a".into()),
468            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
469        ]);
470
471        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
472        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
473        assert!(snapshot.contains("pool.max"));
474        assert!(!snapshot.contains("pool.min"));
475    }
476
477    #[test]
478    fn a_missing_path_and_a_wrong_type_are_told_apart() {
479        let snapshot = snapshot(&[("host", "a".into())]);
480
481        assert_eq!(
482            snapshot.get::<String>("nowhere").unwrap_err().kind(),
483            ErrorKind::Missing
484        );
485        assert_eq!(
486            snapshot.get::<u16>("host").unwrap_err().kind(),
487            ErrorKind::Type
488        );
489        // Walking through a scalar is a missing path, not a type error.
490        assert_eq!(
491            snapshot.get::<u16>("host.port").unwrap_err().kind(),
492            ErrorKind::Missing
493        );
494    }
495
496    #[test]
497    fn a_sub_snapshot_carries_only_its_own_table() {
498        let snapshot = snapshot(&[
499            ("host", "a".into()),
500            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
501        ]);
502
503        let pool = snapshot.sub("pool").expect("`pool` is a table");
504
505        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
506        assert!(!pool.contains("host"));
507
508        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
509    }
510
511    #[test]
512    fn leaf_paths_reach_into_nested_tables() {
513        let snapshot = snapshot(&[
514            ("host", "a".into()),
515            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
516        ]);
517
518        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
519        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
520    }
521
522    #[test]
523    fn extraction_reports_the_path_it_failed_at() {
524        #[derive(serde::Deserialize, Debug)]
525        #[allow(dead_code)]
526        struct Target {
527            port: u16,
528        }
529
530        let error = snapshot(&[("port", "not-a-number".into())])
531            .extract::<Target>()
532            .unwrap_err();
533
534        assert_eq!(error.path(), "port");
535    }
536
537    mod properties {
538        use super::*;
539        use proptest::prelude::*;
540
541        proptest! {
542            #![proptest_config(ProptestConfig::with_cases(256))]
543
544            /// A diff never panics and never reports a value, whatever the
545            /// two trees hold — the security property, fuzzed.
546            #[test]
547            fn diff_reports_paths_never_values(
548                a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
549                b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
550            ) {
551                let left = Snapshot::new(
552                    a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
553                );
554                let right = Snapshot::new(
555                    b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
556                );
557
558                for change in left.diff(&right) {
559                    let rendered = change.to_string();
560
561                    for value in a.values().chain(b.values()) {
562                        prop_assert!(
563                            !rendered.contains(value.as_str()),
564                            "a diff must name paths, never values: {}",
565                            rendered
566                        );
567                    }
568                }
569            }
570        }
571    }
572}