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            // Through the loader's translation, which strips the offending
131            // value out of the backend's message. This used to render
132            // `error.to_string()`, and figment renders a type mismatch as
133            // ``found string "hunter2"`` — the leak the rest of the crate is
134            // built to prevent, on the door that skips the loader.
135            .map_err(|error: figment::Error| crate::loader::translate(&error))
136    }
137
138    /// Every key that differs between `self` and `other`, in path order.
139    ///
140    /// `self` is the earlier snapshot, so a key present only in `other` is
141    /// [`Added`](ChangeKind::Added).
142    #[must_use]
143    pub fn diff(&self, other: &Self) -> Vec<Change> {
144        let mut changes = Vec::new();
145
146        compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
147        changes.sort_by(|left, right| left.path.cmp(&right.path));
148
149        changes
150    }
151
152    /// Reads one value by dotted path, without a struct to hold it.
153    ///
154    /// For the shape a program does not know at compile time: a plugin's
155    /// section, a user-defined table. Everything else should go through a
156    /// struct, where a typo is a compile error rather than a runtime one.
157    ///
158    /// **This deserializes on every call**, which a `current()` on a struct
159    /// does not — it is a diagnostic-grade read, not a request-path one. A
160    /// schemaless configuration that wants the cheap door holds the resolved
161    /// tree instead and walks it: `Dynamic<Value>` plus
162    /// [`Value::get`](crate::Value::get). The book's schemaless chapter
163    /// prints both numbers.
164    ///
165    /// # Errors
166    ///
167    /// If nothing supplies `path`, or the value cannot become `T`. The
168    /// message names the path and the kind of thing that was there, never
169    /// the value.
170    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
171        let value = self.at(path).ok_or_else(|| {
172            Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
173        })?;
174
175        // See `extract`: the translation is what keeps a mistyped secret out
176        // of the message.
177        value
178            .deserialize()
179            .map_err(|error: figment::Error| crate::loader::translate(&error).prepend_key(path))
180    }
181
182    /// This snapshot minus one top-level key — how the cache strips its own
183    /// marker before the values are handed back as configuration.
184    pub(crate) fn without_top_level(&self, key: &str) -> Self {
185        let mut values = self.values().clone();
186        values.remove(key);
187
188        let prefix = format!("{key}.");
189        let provenance = self
190            .provenance
191            .iter()
192            .filter(|(path, _)| *path != key && !path.starts_with(&prefix))
193            .map(|(path, origin)| (path.clone(), origin.clone()))
194            .collect();
195
196        Self { values, provenance }
197    }
198
199    /// Whether anything supplies `path`.
200    #[must_use]
201    pub fn contains(&self, path: &str) -> bool {
202        self.at(path).is_some()
203    }
204
205    /// The table at `path`, as a snapshot of its own.
206    ///
207    /// The analogue of Viper's `Sub`: hand a subsystem the part of the
208    /// configuration it owns and nothing else. Provenance follows: the sub-
209    /// snapshot's [`source_of`](Self::source_of) answers for its own,
210    /// re-rooted paths.
211    #[must_use]
212    pub fn sub(&self, path: &str) -> Option<Self> {
213        match self.at(path)? {
214            Value::Dict(_, nested) => {
215                let prefix = format!("{path}.");
216                let provenance = self
217                    .provenance
218                    .iter()
219                    .filter_map(|(leaf, origin)| {
220                        leaf.strip_prefix(&prefix)
221                            .map(|rest| (rest.to_owned(), origin.clone()))
222                    })
223                    .collect();
224
225                Some(Self {
226                    values: nested.clone(),
227                    provenance,
228                })
229            }
230            _ => None,
231        }
232    }
233
234    fn at(&self, path: &str) -> Option<&Value> {
235        let mut segments = path.split('.');
236        let mut current = self.values.get(segments.next()?)?;
237
238        for segment in segments {
239            let Value::Dict(_, nested) = current else {
240                return None;
241            };
242
243            current = nested.get(segment)?;
244        }
245
246        Some(current)
247    }
248
249    /// The dotted path of every leaf, in order.
250    #[must_use]
251    pub fn leaf_paths(&self) -> Vec<String> {
252        let mut paths = Vec::new();
253
254        collect_leaves(&self.values, &mut Vec::new(), &mut paths);
255
256        paths
257    }
258
259    /// The section's immediate keys — the level a struct's fields map onto.
260    #[must_use]
261    pub fn top_level_keys(&self) -> Vec<String> {
262        self.values.keys().cloned().collect()
263    }
264
265    /// The resolved tree, for the few places that need it whole.
266    pub(crate) fn values(&self) -> &Dict {
267        &self.values
268    }
269
270    /// Whether the section resolved to nothing at all.
271    #[must_use]
272    pub fn is_empty(&self) -> bool {
273        self.values.is_empty()
274    }
275}
276
277/// Keys and shape only, never values: a snapshot holds the *resolved*
278/// configuration, secrets included, and `{:?}` in a log line is exactly how
279/// resolved secrets leak. The dropped `#[derive(Debug)]` is the mistake
280/// AGENTS.md warns about, made by this crate itself.
281impl fmt::Debug for Snapshot {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.debug_struct("Snapshot")
284            .field("keys", &self.top_level_keys())
285            .field("leaves", &self.leaf_paths().len())
286            .field("provenance", &self.provenance.len())
287            .finish_non_exhaustive()
288    }
289}
290
291/// The dotted paths that differ between two configuration values.
292///
293/// The audit half of a reload hook: `on_reload` hands over both structs, and
294/// this names what moved — paths only, never values, same as every other
295/// diagnostic here.
296///
297/// ```
298/// # use serde::Serialize;
299/// #[derive(Serialize)]
300/// struct Db { host: String, port: u16 }
301///
302/// let before = Db { host: "a".into(), port: 1 };
303/// let after = Db { host: "a".into(), port: 2 };
304///
305/// let changes = dynamic_config::changed_paths(&before, &after).unwrap();
306/// assert_eq!(changes.len(), 1);
307/// assert_eq!(changes[0].path, "port");
308/// ```
309///
310/// # Errors
311///
312/// If either value does not serialize to a table — a bare scalar has no
313/// paths to compare.
314pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
315    let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
316        match Value::serialize(value) {
317            Ok(Value::Dict(_, dict)) => Ok(Snapshot::new(dict)),
318            Ok(_) => Err(Error::new(
319                ErrorKind::Type,
320                "only a table has paths to compare; this serializes to a scalar",
321            )),
322            Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
323        }
324    };
325
326    Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
327}
328
329/// Records the dotted path of every leaf, treating an empty table as one.
330fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
331    for (key, value) in values {
332        path.push(key.clone());
333
334        match value {
335            Value::Dict(_, nested) if !nested.is_empty() => {
336                collect_leaves(nested, path, paths);
337            }
338            _ => paths.push(path.join(".")),
339        }
340
341        path.pop();
342    }
343}
344
345/// Walks two tables in step, recording the leaves that differ.
346fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
347    for (key, before) in previous {
348        path.push(key.clone());
349
350        match current.get(key) {
351            Some(after) => compare_values(before, after, path, changes),
352            None => changes.push(change(path, ChangeKind::Removed)),
353        }
354
355        path.pop();
356    }
357
358    for key in current.keys() {
359        if previous.contains_key(key) {
360            continue;
361        }
362
363        path.push(key.clone());
364        changes.push(change(path, ChangeKind::Added));
365        path.pop();
366    }
367}
368
369fn compare_values(
370    before: &Value,
371    after: &Value,
372    path: &mut Vec<String>,
373    changes: &mut Vec<Change>,
374) {
375    match (before, after) {
376        // Two tables are compared key by key, so a change deep inside one is
377        // reported at the leaf that actually moved rather than at the table.
378        (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
379        _ if values_equal(before, after) => {}
380        _ => changes.push(change(path, ChangeKind::Modified)),
381    }
382}
383
384/// Compared through this crate's own untagged [`Value`](crate::Value) — the
385/// tree [`Snapshot::to_value`] already hands out — rather than through the
386/// figment value in hand.
387///
388/// figment's value carries a provenance tag and a numeric *width*, neither of
389/// which is part of the configuration: an integer that arrived as `u8` from
390/// one provider and `u64` from another is the same setting. This used to
391/// compare `Debug` renderings, which stripped the tag but kept the width, so
392/// a reload could report every such key as changed — and which made
393/// correctness rest on an upstream crate's rendering, where a future addition
394/// to it would be silent in both directions.
395fn values_equal(before: &Value, after: &Value) -> bool {
396    crate::value::from_figment(before) == crate::value::from_figment(after)
397}
398
399fn change(path: &[String], kind: ChangeKind) -> Change {
400    Change {
401        path: path.join("."),
402        kind,
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    fn dict(entries: &[(&str, Value)]) -> Dict {
411        entries
412            .iter()
413            .map(|(key, value)| ((*key).to_owned(), value.clone()))
414            .collect()
415    }
416
417    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
418        Snapshot::new(dict(entries))
419    }
420
421    #[test]
422    fn identical_snapshots_have_no_changes() {
423        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
424        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
425
426        assert!(one.diff(&two).is_empty());
427    }
428
429    /// A value as a provider hands it over — figment records where it came
430    /// from in a tag, and two providers tag the same value differently.
431    fn from_provider(key: &str, value: impl serde::Serialize) -> Value {
432        figment::Figment::from((key, value))
433            .find_value(key)
434            .expect("the provider supplies exactly this key")
435    }
436
437    #[test]
438    fn the_same_value_from_two_providers_is_not_a_change() {
439        let (left, right) = (from_provider("host", "a"), from_provider("host", "a"));
440
441        assert_ne!(
442            left.tag(),
443            right.tag(),
444            "the point of this test is two differently tagged values"
445        );
446
447        assert!(snapshot(&[("host", left)])
448            .diff(&snapshot(&[("host", right)]))
449            .is_empty());
450    }
451
452    /// The width an integer arrived at is the provider's business, not the
453    /// configuration's: `5432` from a `u8`-shaped default and `5432` from a
454    /// JSON file are one setting. Comparing rendered figment values called
455    /// that a change on every reload.
456    #[test]
457    fn the_same_number_at_two_widths_is_not_a_change() {
458        let narrow = snapshot(&[("port", Value::from(1u8))]);
459        let wide = snapshot(&[("port", Value::from(1u64))]);
460
461        assert!(narrow.diff(&wide).is_empty());
462    }
463
464    /// And the line that stays drawn: a whole number is not the float that
465    /// prints the same.
466    #[test]
467    fn an_integer_and_a_float_are_different_values() {
468        let integer = snapshot(&[("ratio", Value::from(1u8))]);
469        let float = snapshot(&[("ratio", Value::from(1.0f64))]);
470
471        let changes = integer.diff(&float);
472
473        assert_eq!(changes.len(), 1);
474        assert_eq!(changes[0].path, "ratio");
475        assert_eq!(changes[0].kind, ChangeKind::Modified);
476    }
477
478    #[test]
479    fn a_modified_value_names_its_key_but_not_its_value() {
480        let one = snapshot(&[("password", "hunter2".into())]);
481        let two = snapshot(&[("password", "letmein".into())]);
482
483        let changes = one.diff(&two);
484
485        assert_eq!(changes.len(), 1);
486        assert_eq!(changes[0].path, "password");
487        assert_eq!(changes[0].kind, ChangeKind::Modified);
488
489        let rendered = changes[0].to_string();
490        assert_eq!(rendered, "password changed");
491        assert!(!rendered.contains("hunter2"), "{rendered}");
492        assert!(!rendered.contains("letmein"), "{rendered}");
493    }
494
495    #[test]
496    fn additions_and_removals_are_told_apart() {
497        let one = snapshot(&[("gone", 1u16.into())]);
498        let two = snapshot(&[("fresh", 1u16.into())]);
499
500        let changes = one.diff(&two);
501
502        assert_eq!(
503            changes,
504            [
505                Change {
506                    path: "fresh".to_owned(),
507                    kind: ChangeKind::Added,
508                },
509                Change {
510                    path: "gone".to_owned(),
511                    kind: ChangeKind::Removed,
512                },
513            ]
514        );
515    }
516
517    #[test]
518    fn a_change_inside_a_table_is_reported_at_the_leaf() {
519        let one = snapshot(&[(
520            "pool",
521            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
522        )]);
523        let two = snapshot(&[(
524            "pool",
525            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
526        )]);
527
528        let changes = one.diff(&two);
529
530        assert_eq!(changes.len(), 1);
531        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
532    }
533
534    #[test]
535    fn a_table_replaced_by_a_scalar_is_one_change() {
536        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
537        let two = snapshot(&[("pool", 1u16.into())]);
538
539        let changes = one.diff(&two);
540
541        assert_eq!(changes.len(), 1);
542        assert_eq!(changes[0].path, "pool");
543        assert_eq!(changes[0].kind, ChangeKind::Modified);
544    }
545
546    #[test]
547    fn a_value_can_be_read_by_path_without_a_struct() {
548        let snapshot = snapshot(&[
549            ("host", "a".into()),
550            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
551        ]);
552
553        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
554        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
555        assert!(snapshot.contains("pool.max"));
556        assert!(!snapshot.contains("pool.min"));
557    }
558
559    #[test]
560    fn a_missing_path_and_a_wrong_type_are_told_apart() {
561        let snapshot = snapshot(&[("host", "a".into())]);
562
563        assert_eq!(
564            snapshot.get::<String>("nowhere").unwrap_err().kind(),
565            ErrorKind::Missing
566        );
567        assert_eq!(
568            snapshot.get::<u16>("host").unwrap_err().kind(),
569            ErrorKind::Type
570        );
571        // Walking through a scalar is a missing path, not a type error.
572        assert_eq!(
573            snapshot.get::<u16>("host.port").unwrap_err().kind(),
574            ErrorKind::Missing
575        );
576    }
577
578    #[test]
579    fn a_sub_snapshot_carries_only_its_own_table() {
580        let snapshot = snapshot(&[
581            ("host", "a".into()),
582            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
583        ]);
584
585        let pool = snapshot.sub("pool").expect("`pool` is a table");
586
587        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
588        assert!(!pool.contains("host"));
589
590        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
591    }
592
593    #[test]
594    fn leaf_paths_reach_into_nested_tables() {
595        let snapshot = snapshot(&[
596            ("host", "a".into()),
597            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
598        ]);
599
600        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
601        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
602    }
603
604    #[test]
605    fn extraction_reports_the_path_it_failed_at() {
606        #[derive(serde::Deserialize, Debug)]
607        #[allow(dead_code)]
608        struct Target {
609            port: u16,
610        }
611
612        let error = snapshot(&[("port", "not-a-number".into())])
613            .extract::<Target>()
614            .unwrap_err();
615
616        assert_eq!(error.path(), "port");
617    }
618
619    mod properties {
620        use super::*;
621        use proptest::prelude::*;
622
623        proptest! {
624            #![proptest_config(ProptestConfig::with_cases(256))]
625
626            /// A diff never panics and never reports a value, whatever the
627            /// two trees hold — the security property, fuzzed.
628            #[test]
629            fn diff_reports_paths_never_values(
630                a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
631                b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
632            ) {
633                let left = Snapshot::new(
634                    a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
635                );
636                let right = Snapshot::new(
637                    b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
638                );
639
640                for change in left.diff(&right) {
641                    let rendered = change.to_string();
642
643                    for value in a.values().chain(b.values()) {
644                        prop_assert!(
645                            !rendered.contains(value.as_str()),
646                            "a diff must name paths, never values: {}",
647                            rendered
648                        );
649                    }
650                }
651            }
652        }
653    }
654}