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    /// A stable digest of these values, with `secrets` masked.
242    ///
243    /// Rendered as `sha256:…`, and **safe to log**: every path named in
244    /// `secrets` is masked by position before hashing, so the digest moves
245    /// when a secret appears or disappears and stays put when one merely
246    /// rotates. A digest that moved on rotation would be an oracle for the
247    /// value that moved it.
248    ///
249    /// Computed over the resolved tree rather than any rendering of it, so
250    /// the same configuration fingerprints the same whether it arrived as
251    /// TOML on one host and YAML on another, and whichever order its keys
252    /// were written in.
253    #[must_use]
254    pub fn fingerprint(&self, secrets: &[&str]) -> String {
255        let document = match self.to_value() {
256            crate::Value::Table(table) => table,
257            // A snapshot is a table by construction: it is the resolved
258            // section, and a section is keys.
259            _ => std::collections::BTreeMap::new(),
260        };
261
262        crate::fingerprint::rendered(&document, secrets)
263    }
264
265    /// The dotted path of every leaf, in order.
266    #[must_use]
267    pub fn leaf_paths(&self) -> Vec<String> {
268        let mut paths = Vec::new();
269
270        collect_leaves(&self.values, &mut Vec::new(), &mut paths);
271
272        paths
273    }
274
275    /// The section's immediate keys — the level a struct's fields map onto.
276    #[must_use]
277    pub fn top_level_keys(&self) -> Vec<String> {
278        self.values.keys().cloned().collect()
279    }
280
281    /// The resolved tree, for the few places that need it whole.
282    pub(crate) fn values(&self) -> &Table {
283        &self.values
284    }
285
286    /// Whether the section resolved to nothing at all.
287    #[must_use]
288    pub fn is_empty(&self) -> bool {
289        self.values.is_empty()
290    }
291}
292
293/// Keys and shape only, never values: a snapshot holds the *resolved*
294/// configuration, secrets included, and `{:?}` in a log line is exactly how
295/// resolved secrets leak. The dropped `#[derive(Debug)]` is the mistake
296/// AGENTS.md warns about, made by this crate itself.
297impl fmt::Debug for Snapshot {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        f.debug_struct("Snapshot")
300            .field("keys", &self.top_level_keys())
301            .field("leaves", &self.leaf_paths().len())
302            .field("provenance", &self.provenance.len())
303            .finish_non_exhaustive()
304    }
305}
306
307/// The dotted paths that differ between two configuration values.
308///
309/// The audit half of a reload hook: `on_reload` hands over both structs, and
310/// this names what moved — paths only, never values, same as every other
311/// diagnostic here.
312///
313/// ```
314/// # use serde::Serialize;
315/// #[derive(Serialize)]
316/// struct Db { host: String, port: u16 }
317///
318/// let before = Db { host: "a".into(), port: 1 };
319/// let after = Db { host: "a".into(), port: 2 };
320///
321/// let changes = dynamic_config::changed_paths(&before, &after).unwrap();
322/// assert_eq!(changes.len(), 1);
323/// assert_eq!(changes[0].path, "port");
324/// ```
325///
326/// # Errors
327///
328/// If either value does not serialize to a table — a bare scalar has no
329/// paths to compare.
330pub fn changed_paths<T: serde::Serialize>(previous: &T, current: &T) -> Result<Vec<Change>, Error> {
331    let as_snapshot = |value: &T| -> Result<Snapshot, Error> {
332        match crate::ser::to_value(value) {
333            Ok(crate::Value::Table(table)) => Ok(Snapshot::new(table)),
334            Ok(_) => Err(Error::new(
335                ErrorKind::Type,
336                "only a table has paths to compare; this serializes to a scalar",
337            )),
338            Err(error) => Err(Error::new(ErrorKind::Type, error.to_string())),
339        }
340    };
341
342    Ok(as_snapshot(previous)?.diff(&as_snapshot(current)?))
343}
344
345/// Records the dotted path of every leaf, treating an empty table as one.
346fn collect_leaves(values: &Table, path: &mut Vec<String>, paths: &mut Vec<String>) {
347    for (key, value) in values {
348        path.push(key.clone());
349
350        match value {
351            Value::Table(nested) if !nested.is_empty() => {
352                collect_leaves(nested, path, paths);
353            }
354            _ => paths.push(path.join(".")),
355        }
356
357        path.pop();
358    }
359}
360
361/// Walks two tables in step, recording the leaves that differ.
362fn compare(previous: &Table, current: &Table, path: &mut Vec<String>, changes: &mut Vec<Change>) {
363    for (key, before) in previous {
364        path.push(key.clone());
365
366        match current.get(key) {
367            Some(after) => compare_values(before, after, path, changes),
368            None => changes.push(change(path, ChangeKind::Removed)),
369        }
370
371        path.pop();
372    }
373
374    for key in current.keys() {
375        if previous.contains_key(key) {
376            continue;
377        }
378
379        path.push(key.clone());
380        changes.push(change(path, ChangeKind::Added));
381        path.pop();
382    }
383}
384
385fn compare_values(
386    before: &Value,
387    after: &Value,
388    path: &mut Vec<String>,
389    changes: &mut Vec<Change>,
390) {
391    match (before, after) {
392        // Two tables are compared key by key, so a change deep inside one is
393        // reported at the leaf that actually moved rather than at the table.
394        (Value::Table(before), Value::Table(after)) => compare(before, after, path, changes),
395        // No tag and no width to look past: the tree carries what the
396        // configuration said and nothing about how it was carried.
397        _ if before == after => {}
398        _ => changes.push(change(path, ChangeKind::Modified)),
399    }
400}
401
402fn change(path: &[String], kind: ChangeKind) -> Change {
403    Change {
404        path: path.join("."),
405        kind,
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn dict(entries: &[(&str, Value)]) -> Table {
414        entries
415            .iter()
416            .map(|(key, value)| ((*key).to_owned(), value.clone()))
417            .collect()
418    }
419
420    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
421        Snapshot::new(dict(entries))
422    }
423
424    #[test]
425    fn identical_snapshots_have_no_changes() {
426        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
427        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
428
429        assert!(one.diff(&two).is_empty());
430    }
431
432    /// A value as it arrives from a provider, through the conversion every
433    /// resolved document goes through.
434    ///
435    /// The backend records *where* a value came from beside the value, and
436    /// two providers record that differently for the same setting. The tree
437    /// this crate keeps holds the configuration and not the bookkeeping, so
438    /// the difference cannot reach a diff — which is the property this test
439    /// exists for, and it now holds by construction rather than by a
440    /// comparison that remembered to look past a tag.
441    fn from_provider(key: &str, value: impl serde::Serialize) -> Value {
442        let supplied = figment::Figment::from((key, value))
443            .find_value(key)
444            .expect("the provider supplies exactly this key");
445
446        crate::backend::figment::from_figment(&supplied)
447    }
448
449    #[test]
450    fn the_same_value_from_two_providers_is_not_a_change() {
451        let (left, right) = (from_provider("host", "a"), from_provider("host", "a"));
452
453        assert!(snapshot(&[("host", left)])
454            .diff(&snapshot(&[("host", right)]))
455            .is_empty());
456    }
457
458    /// The width an integer arrived at is the provider's business, not the
459    /// configuration's: `5432` from a `u8`-shaped default and `5432` from a
460    /// JSON file are one setting. Comparing rendered backend values called
461    /// that a change on every reload.
462    #[test]
463    fn the_same_number_at_two_widths_is_not_a_change() {
464        let narrow = snapshot(&[("port", from_provider("port", 1u8))]);
465        let wide = snapshot(&[("port", from_provider("port", 1u64))]);
466
467        assert!(narrow.diff(&wide).is_empty());
468    }
469
470    /// And the line that stays drawn: a whole number is not the float that
471    /// prints the same.
472    #[test]
473    fn an_integer_and_a_float_are_different_values() {
474        let integer = snapshot(&[("ratio", Value::from(1u8))]);
475        let float = snapshot(&[("ratio", Value::from(1.0f64))]);
476
477        let changes = integer.diff(&float);
478
479        assert_eq!(changes.len(), 1);
480        assert_eq!(changes[0].path, "ratio");
481        assert_eq!(changes[0].kind, ChangeKind::Modified);
482    }
483
484    #[test]
485    fn a_modified_value_names_its_key_but_not_its_value() {
486        let one = snapshot(&[("password", "hunter2".into())]);
487        let two = snapshot(&[("password", "letmein".into())]);
488
489        let changes = one.diff(&two);
490
491        assert_eq!(changes.len(), 1);
492        assert_eq!(changes[0].path, "password");
493        assert_eq!(changes[0].kind, ChangeKind::Modified);
494
495        let rendered = changes[0].to_string();
496        assert_eq!(rendered, "password changed");
497        assert!(!rendered.contains("hunter2"), "{rendered}");
498        assert!(!rendered.contains("letmein"), "{rendered}");
499    }
500
501    #[test]
502    fn additions_and_removals_are_told_apart() {
503        let one = snapshot(&[("gone", 1u16.into())]);
504        let two = snapshot(&[("fresh", 1u16.into())]);
505
506        let changes = one.diff(&two);
507
508        assert_eq!(
509            changes,
510            [
511                Change {
512                    path: "fresh".to_owned(),
513                    kind: ChangeKind::Added,
514                },
515                Change {
516                    path: "gone".to_owned(),
517                    kind: ChangeKind::Removed,
518                },
519            ]
520        );
521    }
522
523    #[test]
524    fn a_change_inside_a_table_is_reported_at_the_leaf() {
525        let one = snapshot(&[(
526            "pool",
527            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
528        )]);
529        let two = snapshot(&[(
530            "pool",
531            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
532        )]);
533
534        let changes = one.diff(&two);
535
536        assert_eq!(changes.len(), 1);
537        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
538    }
539
540    #[test]
541    fn a_table_replaced_by_a_scalar_is_one_change() {
542        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
543        let two = snapshot(&[("pool", 1u16.into())]);
544
545        let changes = one.diff(&two);
546
547        assert_eq!(changes.len(), 1);
548        assert_eq!(changes[0].path, "pool");
549        assert_eq!(changes[0].kind, ChangeKind::Modified);
550    }
551
552    #[test]
553    fn a_value_can_be_read_by_path_without_a_struct() {
554        let snapshot = snapshot(&[
555            ("host", "a".into()),
556            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
557        ]);
558
559        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
560        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
561        assert!(snapshot.contains("pool.max"));
562        assert!(!snapshot.contains("pool.min"));
563    }
564
565    #[test]
566    fn a_missing_path_and_a_wrong_type_are_told_apart() {
567        let snapshot = snapshot(&[("host", "a".into())]);
568
569        assert_eq!(
570            snapshot.get::<String>("nowhere").unwrap_err().kind(),
571            ErrorKind::Missing
572        );
573        assert_eq!(
574            snapshot.get::<u16>("host").unwrap_err().kind(),
575            ErrorKind::Type
576        );
577        // Walking through a scalar is a missing path, not a type error.
578        assert_eq!(
579            snapshot.get::<u16>("host.port").unwrap_err().kind(),
580            ErrorKind::Missing
581        );
582    }
583
584    #[test]
585    fn a_sub_snapshot_carries_only_its_own_table() {
586        let snapshot = snapshot(&[
587            ("host", "a".into()),
588            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
589        ]);
590
591        let pool = snapshot.sub("pool").expect("`pool` is a table");
592
593        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
594        assert!(!pool.contains("host"));
595
596        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
597    }
598
599    #[test]
600    fn leaf_paths_reach_into_nested_tables() {
601        let snapshot = snapshot(&[
602            ("host", "a".into()),
603            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
604        ]);
605
606        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
607        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
608    }
609
610    #[test]
611    fn extraction_reports_the_path_it_failed_at() {
612        #[derive(serde::Deserialize, Debug)]
613        #[allow(dead_code)]
614        struct Target {
615            port: u16,
616        }
617
618        let error = snapshot(&[("port", "not-a-number".into())])
619            .extract::<Target>()
620            .unwrap_err();
621
622        assert_eq!(error.path(), "port");
623    }
624
625    mod properties {
626        use super::*;
627        use proptest::prelude::*;
628
629        proptest! {
630            #![proptest_config(ProptestConfig::with_cases(256))]
631
632            /// A diff never panics and never reports a value, whatever the
633            /// two trees hold — the security property, fuzzed.
634            #[test]
635            fn diff_reports_paths_never_values(
636                a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
637                b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
638            ) {
639                let left = Snapshot::new(
640                    a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
641                );
642                let right = Snapshot::new(
643                    b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
644                );
645
646                for change in left.diff(&right) {
647                    let rendered = change.to_string();
648
649                    for value in a.values().chain(b.values()) {
650                        prop_assert!(
651                            !rendered.contains(value.as_str()),
652                            "a diff must name paths, never values: {}",
653                            rendered
654                        );
655                    }
656                }
657            }
658        }
659    }
660}