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::fmt;
13
14use figment::value::{Dict, Value};
15use serde::de::DeserializeOwned;
16
17use crate::error::{Error, ErrorKind};
18
19/// What happened to one key between two snapshots.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum ChangeKind {
23    /// Nothing supplied it before.
24    Added,
25    /// Nothing supplies it any more.
26    Removed,
27    /// It has a different value.
28    Modified,
29}
30
31impl fmt::Display for ChangeKind {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(match self {
34            Self::Added => "added",
35            Self::Removed => "removed",
36            Self::Modified => "changed",
37        })
38    }
39}
40
41/// One difference between two snapshots.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Change {
44    /// Dotted key path, relative to the section.
45    pub path: String,
46    /// What happened to it.
47    pub kind: ChangeKind,
48}
49
50impl fmt::Display for Change {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "{} {}", self.path, self.kind)
53    }
54}
55
56/// A resolved configuration section, before it becomes a struct.
57///
58/// Obtained from [`snapshot`](crate::snapshot), compared with
59/// [`diff`](Self::diff), and turned into a struct with
60/// [`extract`](Self::extract).
61#[derive(Debug, Clone, Default)]
62pub struct Snapshot {
63    values: Dict,
64}
65
66impl Snapshot {
67    pub(crate) fn new(values: Dict) -> Self {
68        Self { values }
69    }
70
71    /// Deserializes the section into `T`.
72    ///
73    /// # Errors
74    ///
75    /// If a required value is missing or cannot become the field's type.
76    ///
77    /// Errors here carry the key path but **not** the originating file or
78    /// variable: provenance lives in figment's metadata, which a snapshot has
79    /// already left behind. Call [`load`](crate::load) when the error is going
80    /// to be read by a person.
81    pub fn extract<T: DeserializeOwned>(&self) -> Result<T, Error> {
82        Value::from(self.values.clone())
83            .deserialize()
84            .map_err(|error: figment::Error| {
85                let mut translated = Error::new(ErrorKind::Type, error.to_string());
86
87                for segment in error.path.iter().rev() {
88                    translated = translated.prepend_key(segment);
89                }
90
91                translated
92            })
93    }
94
95    /// Every key that differs between `self` and `other`, in path order.
96    ///
97    /// `self` is the earlier snapshot, so a key present only in `other` is
98    /// [`Added`](ChangeKind::Added).
99    #[must_use]
100    pub fn diff(&self, other: &Self) -> Vec<Change> {
101        let mut changes = Vec::new();
102
103        compare(&self.values, &other.values, &mut Vec::new(), &mut changes);
104        changes.sort_by(|left, right| left.path.cmp(&right.path));
105
106        changes
107    }
108
109    /// Reads one value by dotted path, without a struct to hold it.
110    ///
111    /// For the shape a program does not know at compile time: a plugin's
112    /// section, a user-defined table. Everything else should go through a
113    /// struct, where a typo is a compile error rather than a runtime one.
114    ///
115    /// # Errors
116    ///
117    /// If nothing supplies `path`, or the value cannot become `T`.
118    pub fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
119        let value = self.at(path).ok_or_else(|| {
120            Error::new(ErrorKind::Missing, "no value at this path").prepend_key(path)
121        })?;
122
123        value.deserialize().map_err(|error: figment::Error| {
124            Error::new(ErrorKind::Type, error.to_string()).prepend_key(path)
125        })
126    }
127
128    /// This snapshot minus one top-level key — how the cache strips its own
129    /// marker before the values are handed back as configuration.
130    pub(crate) fn without_top_level(&self, key: &str) -> Self {
131        let mut values = self.values().clone();
132        values.remove(key);
133
134        Self::new(values)
135    }
136
137    /// Whether anything supplies `path`.
138    #[must_use]
139    pub fn contains(&self, path: &str) -> bool {
140        self.at(path).is_some()
141    }
142
143    /// The table at `path`, as a snapshot of its own.
144    ///
145    /// The analogue of Viper's `Sub`: hand a subsystem the part of the
146    /// configuration it owns and nothing else.
147    #[must_use]
148    pub fn sub(&self, path: &str) -> Option<Self> {
149        match self.at(path)? {
150            Value::Dict(_, nested) => Some(Self::new(nested.clone())),
151            _ => None,
152        }
153    }
154
155    fn at(&self, path: &str) -> Option<&Value> {
156        let mut segments = path.split('.');
157        let mut current = self.values.get(segments.next()?)?;
158
159        for segment in segments {
160            let Value::Dict(_, nested) = current else {
161                return None;
162            };
163
164            current = nested.get(segment)?;
165        }
166
167        Some(current)
168    }
169
170    /// The dotted path of every leaf, in order.
171    #[must_use]
172    pub fn leaf_paths(&self) -> Vec<String> {
173        let mut paths = Vec::new();
174
175        collect_leaves(&self.values, &mut Vec::new(), &mut paths);
176
177        paths
178    }
179
180    /// The section's immediate keys — the level a struct's fields map onto.
181    #[must_use]
182    pub fn top_level_keys(&self) -> Vec<String> {
183        self.values.keys().cloned().collect()
184    }
185
186    /// The resolved tree, for the few places that need it whole.
187    pub(crate) fn values(&self) -> &Dict {
188        &self.values
189    }
190
191    /// Whether the section resolved to nothing at all.
192    #[must_use]
193    pub fn is_empty(&self) -> bool {
194        self.values.is_empty()
195    }
196}
197
198/// Records the dotted path of every leaf, treating an empty table as one.
199fn collect_leaves(values: &Dict, path: &mut Vec<String>, paths: &mut Vec<String>) {
200    for (key, value) in values {
201        path.push(key.clone());
202
203        match value {
204            Value::Dict(_, nested) if !nested.is_empty() => {
205                collect_leaves(nested, path, paths);
206            }
207            _ => paths.push(path.join(".")),
208        }
209
210        path.pop();
211    }
212}
213
214/// Walks two tables in step, recording the leaves that differ.
215fn compare(previous: &Dict, current: &Dict, path: &mut Vec<String>, changes: &mut Vec<Change>) {
216    for (key, before) in previous {
217        path.push(key.clone());
218
219        match current.get(key) {
220            Some(after) => compare_values(before, after, path, changes),
221            None => changes.push(change(path, ChangeKind::Removed)),
222        }
223
224        path.pop();
225    }
226
227    for key in current.keys() {
228        if previous.contains_key(key) {
229            continue;
230        }
231
232        path.push(key.clone());
233        changes.push(change(path, ChangeKind::Added));
234        path.pop();
235    }
236}
237
238fn compare_values(
239    before: &Value,
240    after: &Value,
241    path: &mut Vec<String>,
242    changes: &mut Vec<Change>,
243) {
244    match (before, after) {
245        // Two tables are compared key by key, so a change deep inside one is
246        // reported at the leaf that actually moved rather than at the table.
247        (Value::Dict(_, before), Value::Dict(_, after)) => compare(before, after, path, changes),
248        _ if values_equal(before, after) => {}
249        _ => changes.push(change(path, ChangeKind::Modified)),
250    }
251}
252
253/// figment values carry a provenance tag that takes part in `PartialEq`, so two
254/// identical values from different providers compare unequal. Rendering strips
255/// the tag, which is the comparison anyone actually means here.
256fn values_equal(before: &Value, after: &Value) -> bool {
257    format!("{before:?}") == format!("{after:?}")
258}
259
260fn change(path: &[String], kind: ChangeKind) -> Change {
261    Change {
262        path: path.join("."),
263        kind,
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn dict(entries: &[(&str, Value)]) -> Dict {
272        entries
273            .iter()
274            .map(|(key, value)| ((*key).to_owned(), value.clone()))
275            .collect()
276    }
277
278    fn snapshot(entries: &[(&str, Value)]) -> Snapshot {
279        Snapshot::new(dict(entries))
280    }
281
282    #[test]
283    fn identical_snapshots_have_no_changes() {
284        let one = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
285        let two = snapshot(&[("host", "a".into()), ("port", 1u16.into())]);
286
287        assert!(one.diff(&two).is_empty());
288    }
289
290    #[test]
291    fn a_modified_value_names_its_key_but_not_its_value() {
292        let one = snapshot(&[("password", "hunter2".into())]);
293        let two = snapshot(&[("password", "letmein".into())]);
294
295        let changes = one.diff(&two);
296
297        assert_eq!(changes.len(), 1);
298        assert_eq!(changes[0].path, "password");
299        assert_eq!(changes[0].kind, ChangeKind::Modified);
300
301        let rendered = changes[0].to_string();
302        assert_eq!(rendered, "password changed");
303        assert!(!rendered.contains("hunter2"), "{rendered}");
304        assert!(!rendered.contains("letmein"), "{rendered}");
305    }
306
307    #[test]
308    fn additions_and_removals_are_told_apart() {
309        let one = snapshot(&[("gone", 1u16.into())]);
310        let two = snapshot(&[("fresh", 1u16.into())]);
311
312        let changes = one.diff(&two);
313
314        assert_eq!(
315            changes,
316            [
317                Change {
318                    path: "fresh".to_owned(),
319                    kind: ChangeKind::Added,
320                },
321                Change {
322                    path: "gone".to_owned(),
323                    kind: ChangeKind::Removed,
324                },
325            ]
326        );
327    }
328
329    #[test]
330    fn a_change_inside_a_table_is_reported_at_the_leaf() {
331        let one = snapshot(&[(
332            "pool",
333            Value::from(dict(&[("max", 1u16.into()), ("min", 1u16.into())])),
334        )]);
335        let two = snapshot(&[(
336            "pool",
337            Value::from(dict(&[("max", 2u16.into()), ("min", 1u16.into())])),
338        )]);
339
340        let changes = one.diff(&two);
341
342        assert_eq!(changes.len(), 1);
343        assert_eq!(changes[0].path, "pool.max", "not just `pool`");
344    }
345
346    #[test]
347    fn a_table_replaced_by_a_scalar_is_one_change() {
348        let one = snapshot(&[("pool", Value::from(dict(&[("max", 1u16.into())])))]);
349        let two = snapshot(&[("pool", 1u16.into())]);
350
351        let changes = one.diff(&two);
352
353        assert_eq!(changes.len(), 1);
354        assert_eq!(changes[0].path, "pool");
355        assert_eq!(changes[0].kind, ChangeKind::Modified);
356    }
357
358    #[test]
359    fn a_value_can_be_read_by_path_without_a_struct() {
360        let snapshot = snapshot(&[
361            ("host", "a".into()),
362            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
363        ]);
364
365        assert_eq!(snapshot.get::<String>("host").unwrap(), "a");
366        assert_eq!(snapshot.get::<u16>("pool.max").unwrap(), 32);
367        assert!(snapshot.contains("pool.max"));
368        assert!(!snapshot.contains("pool.min"));
369    }
370
371    #[test]
372    fn a_missing_path_and_a_wrong_type_are_told_apart() {
373        let snapshot = snapshot(&[("host", "a".into())]);
374
375        assert_eq!(
376            snapshot.get::<String>("nowhere").unwrap_err().kind(),
377            ErrorKind::Missing
378        );
379        assert_eq!(
380            snapshot.get::<u16>("host").unwrap_err().kind(),
381            ErrorKind::Type
382        );
383        // Walking through a scalar is a missing path, not a type error.
384        assert_eq!(
385            snapshot.get::<u16>("host.port").unwrap_err().kind(),
386            ErrorKind::Missing
387        );
388    }
389
390    #[test]
391    fn a_sub_snapshot_carries_only_its_own_table() {
392        let snapshot = snapshot(&[
393            ("host", "a".into()),
394            ("pool", Value::from(dict(&[("max", 32u16.into())]))),
395        ]);
396
397        let pool = snapshot.sub("pool").expect("`pool` is a table");
398
399        assert_eq!(pool.get::<u16>("max").unwrap(), 32);
400        assert!(!pool.contains("host"));
401
402        assert!(snapshot.sub("host").is_none(), "a scalar is not a table");
403    }
404
405    #[test]
406    fn leaf_paths_reach_into_nested_tables() {
407        let snapshot = snapshot(&[
408            ("host", "a".into()),
409            ("pool", Value::from(dict(&[("max", 1u16.into())]))),
410        ]);
411
412        assert_eq!(snapshot.leaf_paths(), ["host", "pool.max"]);
413        assert_eq!(snapshot.top_level_keys(), ["host", "pool"]);
414    }
415
416    #[test]
417    fn extraction_reports_the_path_it_failed_at() {
418        #[derive(serde::Deserialize, Debug)]
419        #[allow(dead_code)]
420        struct Target {
421            port: u16,
422        }
423
424        let error = snapshot(&[("port", "not-a-number".into())])
425            .extract::<Target>()
426            .unwrap_err();
427
428        assert_eq!(error.path(), "port");
429    }
430
431    mod properties {
432        use super::*;
433        use proptest::prelude::*;
434
435        proptest! {
436            #![proptest_config(ProptestConfig::with_cases(256))]
437
438            /// A diff never panics and never reports a value, whatever the
439            /// two trees hold — the security property, fuzzed.
440            #[test]
441            fn diff_reports_paths_never_values(
442                a in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
443                b in prop::collection::btree_map("[a-z]{1,8}", "[a-zA-Z0-9]{4,16}", 0..8),
444            ) {
445                let left = Snapshot::new(
446                    a.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
447                );
448                let right = Snapshot::new(
449                    b.iter().map(|(k, v)| (k.clone(), Value::from(v.clone()))).collect(),
450                );
451
452                for change in left.diff(&right) {
453                    let rendered = change.to_string();
454
455                    for value in a.values().chain(b.values()) {
456                        prop_assert!(
457                            !rendered.contains(value.as_str()),
458                            "a diff must name paths, never values: {}",
459                            rendered
460                        );
461                    }
462                }
463            }
464        }
465    }
466}