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