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    /// The resolved section as an owned [`crate::Value`] tree.
101    ///
102    /// For the boundary that needs the configuration as *data* rather than
103    /// a type — a language binding, an exporter. Built by walking the
104    /// resolved tree directly, never through a serialized intermediate;
105    /// like [`extract`](Self::extract), it hands over real values, secrets
106    /// included — the paths-only rule governs what this crate *prints*.
107    #[must_use]
108    pub fn to_value(&self) -> crate::Value {
109        crate::value::Value::Table(
110            self.values
111                .iter()
112                .map(|(key, value)| (key.clone(), crate::value::from_figment(value)))
113                .collect(),
114        )
115    }
116
117    /// Deserializes the section into `T`.
118    ///
119    /// # Errors
120    ///
121    /// If a required value is missing or cannot become the field's type.
122    ///
123    /// Errors here carry the key path but **not** the originating file or
124    /// variable: provenance lives in figment's metadata, which a snapshot has
125    /// already left behind. Call [`load`](crate::load) when the error is going
126    /// to be read by a person.
127    pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
128        Value::from(self.values.clone())
129            .deserialize()
130            .map_err(|error: figment::Error| {
131                let mut translated = Error::new(ErrorKind::Type, error.to_string());
132
133                for segment in error.path.iter().rev() {
134                    translated = translated.prepend_key(segment);
135                }
136
137                translated
138            })
139    }
140
141    /// Every key that differs between `self` and `other`, in path order.
142    ///
143    /// `self` is the earlier snapshot, so a key present only in `other` is
144    /// [`Added`](ChangeKind::Added).
145    #[must_use]
146    pub fn diff(&self, other: &Self) -> Vec<Change> {
147        let mut changes = Vec::new();
148
149        compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
150        changes.sort_by(|left, right| left.path.cmp(&right.path));
151
152        changes
153    }
154
155    /// Reads one value by dotted path, without a struct to hold it.
156    ///
157    /// For the shape a program does not know at compile time: a plugin's
158    /// section, a user-defined table. Everything else should go through a
159    /// struct, where a typo is a compile error rather than a runtime one.
160    ///
161    /// # Errors
162    ///
163    /// If nothing supplies `path`, or the value cannot become `T`.
164    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
165        let value = self.at(path).ok_or_else(|| {
166            Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
167        })?;
168
169        value.deserialize().map_err(|error: figment::Error| {
170            Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
171        })
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    /// The analogue of Viper's `Sub`: hand a subsystem the part of the
200    /// configuration it owns and nothing else. Provenance follows: the sub-
201    /// snapshot's [`source_of`](Self::source_of) answers for its own,
202    /// re-rooted paths.
203    #[must_use]
204    pub fn sub(&self, path: &str) -> Option<Self> {
205        match self.at(path)? {
206            Value::Dict(_, 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::Dict(_, 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) -> &Dict {
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 Value::serialize(value) {
309            Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
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: &Dict, 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::Dict(_, 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: &Dict, current: &Dict, 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::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
371        _ if values_equal(before, after) => {}
372        _ => changes.push(change(path, ChangeKind::Modified)),
373    }
374}
375
376/// figment values carry a provenance tag that takes part in `PartialEq`, so two
377/// identical values from different providers compare unequal. Rendering strips
378/// the tag, which is the comparison anyone actually means here.
379fn values_equal(before: &Value, after: &Value) -> bool {
380    format!("{before:?}") == format!("{after:?}")
381}
382
383fn change(path: &[String], kind: ChangeKind) -> Change {
384    Change {
385        path: path.join("."),
386        kind,
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn dict(entries: &[(&str, Value)]) -> Dict {
395        entries
396            .iter()
397            .map(|(key, value)| ((*key).to_owned(), value.clone()))
398            .collect()
399    }
400
401    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
402        Snapshot::new(dict(entries))
403    }
404
405    #[test]
406    fn identical_snapshots_have_no_changes() {
407        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
408        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
409
410        assert!(one.diff(&two).is_empty());
411    }
412
413    #[test]
414    fn a_modified_value_names_its_key_but_not_its_value() {
415        let one = snapshot(&[("password", "hunter2".into())]);
416        let two = snapshot(&[("password", "letmein".into())]);
417
418        let changes = one.diff(&two);
419
420        assert_eq!(changes.len(), 1);
421        assert_eq!(changes[0].path, "password");
422        assert_eq!(changes[0].kind, ChangeKind::Modified);
423
424        let rendered = changes[0].to_string();
425        assert_eq!(rendered, "password changed");
426        assert!(!rendered.contains("hunter2"), "{rendered}");
427        assert!(!rendered.contains("letmein"), "{rendered}");
428    }
429
430    #[test]
431    fn additions_and_removals_are_told_apart() {
432        let one = snapshot(&[("gone", 1u16.into())]);
433        let two = snapshot(&[("fresh", 1u16.into())]);
434
435        let changes = one.diff(&two);
436
437        assert_eq!(
438            changes,
439            [
440                Change {
441                    path: "fresh".to_owned(),
442                    kind: ChangeKind::Added,
443                },
444                Change {
445                    path: "gone".to_owned(),
446                    kind: ChangeKind::Removed,
447                },
448            ]
449        );
450    }
451
452    #[test]
453    fn a_change_inside_a_table_is_reported_at_the_leaf() {
454        let one = snapshot(&[(
455            "pool",
456            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
457        )]);
458        let two = snapshot(&[(
459            "pool",
460            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
461        )]);
462
463        let changes = one.diff(&two);
464
465        assert_eq!(changes.len(), 1);
466        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
467    }
468
469    #[test]
470    fn a_table_replaced_by_a_scalar_is_one_change() {
471        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
472        let two = snapshot(&[("pool", 1u16.into())]);
473
474        let changes = one.diff(&two);
475
476        assert_eq!(changes.len(), 1);
477        assert_eq!(changes[0].path, "pool");
478        assert_eq!(changes[0].kind, ChangeKind::Modified);
479    }
480
481    #[test]
482    fn a_value_can_be_read_by_path_without_a_struct() {
483        let snapshot = snapshot(&[
484            ("host", "a".into()),
485            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
486        ]);
487
488        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
489        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
490        assert!(snapshot.contains("pool.max"));
491        assert!(!snapshot.contains("pool.min"));
492    }
493
494    #[test]
495    fn a_missing_path_and_a_wrong_type_are_told_apart() {
496        let snapshot = snapshot(&[("host", "a".into())]);
497
498        assert_eq!(
499            snapshot.get::<String>("nowhere").unwrap_err().kind(),
500            ErrorKind::Missing
501        );
502        assert_eq!(
503            snapshot.get::<u16>("host").unwrap_err().kind(),
504            ErrorKind::Type
505        );
506        // Walking through a scalar is a missing path, not a type error.
507        assert_eq!(
508            snapshot.get::<u16>("host.port").unwrap_err().kind(),
509            ErrorKind::Missing
510        );
511    }
512
513    #[test]
514    fn a_sub_snapshot_carries_only_its_own_table() {
515        let snapshot = snapshot(&[
516            ("host", "a".into()),
517            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
518        ]);
519
520        let pool = snapshot.sub("pool").expect("`pool` is a table");
521
522        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
523        assert!(!pool.contains("host"));
524
525        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
526    }
527
528    #[test]
529    fn leaf_paths_reach_into_nested_tables() {
530        let snapshot = snapshot(&[
531            ("host", "a".into()),
532            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
533        ]);
534
535        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
536        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
537    }
538
539    #[test]
540    fn extraction_reports_the_path_it_failed_at() {
541        #[derive(serde::Deserialize, Debug)]
542        #[allow(dead_code)]
543        struct Target {
544            port: u16,
545        }
546
547        let error = snapshot(&[("port", "not-a-number".into())])
548            .extract::<Target>()
549            .unwrap_err();
550
551        assert_eq!(error.path(), "port");
552    }
553
554    mod properties {
555        use super::*;
556        use proptest::prelude::*;
557
558        proptest! {
559            #![proptest_config(ProptestConfig::with_cases(256))]
560
561            /// A diff never panics and never reports a value, whatever the
562            /// two trees hold — the security property, fuzzed.
563            #[test]
564            fn diff_reports_paths_never_values(
565                a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
566                b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
567            ) {
568                let left = Snapshot::new(
569                    a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
570                );
571                let right = Snapshot::new(
572                    b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
573                );
574
575                for change in left.diff(&right) {
576                    let rendered = change.to_string();
577
578                    for value in a.values().chain(b.values()) {
579                        prop_assert!(
580                            !rendered.contains(value.as_str()),
581                            "a diff must name paths, never values: {}",
582                            rendered
583                        );
584                    }
585                }
586            }
587        }
588    }
589}