Skip to main content

engenho_diff/
normalize.rs

1//! The [`Normalizer`] — a typed, ordered list of [`Mask`]s that strips
2//! legitimately-varying fields BEFORE the differ runs. Without it every
3//! diff is ~100% noise (uid/resourceVersion/timestamps differ on every
4//! object). The masks are auditable: strict mode (see [`crate::cotejo`])
5//! re-diffs the RAW objects and re-reports whatever a mask suppressed as
6//! `Severity::Cosmetic`, so a mask can never silently hide a real gap.
7
8use serde_json::Value;
9
10use crate::path::{JsonPath, PathSeg};
11
12/// A pure canonicalization function applied at a path.
13pub type CanonFn = fn(&Value) -> Value;
14
15/// One normalization step.
16#[derive(Clone)]
17pub enum Mask {
18    /// Remove the value at a path (drop the whole subtree).
19    Drop(JsonPath),
20    /// Replace the value at a path with a canonical form.
21    Canonicalize(JsonPath, CanonFn),
22    /// Sort the array at a path by an object key (stable ordering).
23    Sort(JsonPath, String),
24}
25
26/// An ordered set of masks.
27#[derive(Clone, Default)]
28pub struct Normalizer {
29    masks: Vec<Mask>,
30}
31
32impl Normalizer {
33    /// An empty normalizer (identity).
34    #[must_use]
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Append a mask (builder style).
40    #[must_use]
41    pub fn with(mut self, m: Mask) -> Self {
42        self.masks.push(m);
43        self
44    }
45
46    /// The masks, in order.
47    #[must_use]
48    pub fn masks(&self) -> &[Mask] {
49        &self.masks
50    }
51
52    /// Apply every mask in order, returning the normalized clone.
53    #[must_use]
54    pub fn apply(&self, v: &Value) -> Value {
55        let mut out = v.clone();
56        for m in &self.masks {
57            match m {
58                Mask::Drop(p) => walk_drop(&mut out, p.segs()),
59                Mask::Canonicalize(p, f) => walk_canon(&mut out, p.segs(), *f),
60                Mask::Sort(p, key) => walk_sort(&mut out, p.segs(), key),
61            }
62        }
63        out
64    }
65}
66
67fn walk_drop(v: &mut Value, segs: &[PathSeg]) {
68    let Some((seg, rest)) = segs.split_first() else {
69        return;
70    };
71    if rest.is_empty() {
72        match seg {
73            PathSeg::Key(k) => {
74                if let Some(o) = v.as_object_mut() {
75                    o.remove(k);
76                }
77            }
78            PathSeg::Index(i) => {
79                if let Some(a) = v.as_array_mut() {
80                    if *i < a.len() {
81                        a.remove(*i);
82                    }
83                }
84            }
85            PathSeg::Wildcard => {
86                if let Some(a) = v.as_array_mut() {
87                    a.clear();
88                }
89            }
90        }
91        return;
92    }
93    descend_each(v, seg, |child| walk_drop(child, rest));
94}
95
96fn walk_canon(v: &mut Value, segs: &[PathSeg], f: CanonFn) {
97    let Some((seg, rest)) = segs.split_first() else {
98        *v = f(v);
99        return;
100    };
101    if rest.is_empty() {
102        match seg {
103            PathSeg::Key(k) => {
104                if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
105                    *child = f(child);
106                }
107            }
108            PathSeg::Index(i) => {
109                if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
110                    *child = f(child);
111                }
112            }
113            PathSeg::Wildcard => {
114                if let Some(a) = v.as_array_mut() {
115                    for child in a.iter_mut() {
116                        *child = f(child);
117                    }
118                }
119            }
120        }
121        return;
122    }
123    descend_each(v, seg, |child| walk_canon(child, rest, f));
124}
125
126fn walk_sort(v: &mut Value, segs: &[PathSeg], key: &str) {
127    let Some((seg, rest)) = segs.split_first() else {
128        sort_array_by_key(v, key);
129        return;
130    };
131    if rest.is_empty() {
132        match seg {
133            PathSeg::Key(k) => {
134                if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
135                    sort_array_by_key(child, key);
136                }
137            }
138            PathSeg::Index(i) => {
139                if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
140                    sort_array_by_key(child, key);
141                }
142            }
143            PathSeg::Wildcard => {
144                if let Some(a) = v.as_array_mut() {
145                    for child in a.iter_mut() {
146                        sort_array_by_key(child, key);
147                    }
148                }
149            }
150        }
151        return;
152    }
153    descend_each(v, seg, |child| walk_sort(child, rest, key));
154}
155
156fn descend_each(v: &mut Value, seg: &PathSeg, mut f: impl FnMut(&mut Value)) {
157    match seg {
158        PathSeg::Key(k) => {
159            if let Some(child) = v.as_object_mut().and_then(|o| o.get_mut(k)) {
160                f(child);
161            }
162        }
163        PathSeg::Index(i) => {
164            if let Some(child) = v.as_array_mut().and_then(|a| a.get_mut(*i)) {
165                f(child);
166            }
167        }
168        PathSeg::Wildcard => {
169            if let Some(a) = v.as_array_mut() {
170                for child in a.iter_mut() {
171                    f(child);
172                }
173            }
174        }
175    }
176}
177
178fn sort_array_by_key(v: &mut Value, key: &str) {
179    if let Some(arr) = v.as_array_mut() {
180        arr.sort_by(|a, b| {
181            let ak = a.get(key).and_then(Value::as_str).unwrap_or_default();
182            let bk = b.get(key).and_then(Value::as_str).unwrap_or_default();
183            ak.cmp(bk)
184        });
185    }
186}
187
188/// The canonical profile: strip the fields that legitimately vary between
189/// any two conformant apiservers, so what remains is genuine divergence.
190///
191/// Deliberately does NOT mask `spec.finalizers`, `metadata.finalizers`,
192/// `metadata.labels`, or `data` — those carry the real conformance signal
193/// (server-side defaulting). `status` IS masked (pod status, phase, and
194/// friends are genuinely time-varying); strict mode re-surfaces anything
195/// notable it hides as `Cosmetic`.
196#[must_use]
197pub fn volatile_meta() -> Normalizer {
198    Normalizer::new()
199        // Identity / bookkeeping metadata — opaque + server-assigned.
200        .with(Mask::Drop(JsonPath::parse("metadata.uid")))
201        .with(Mask::Drop(JsonPath::parse("metadata.resourceVersion")))
202        .with(Mask::Drop(JsonPath::parse("metadata.generation")))
203        .with(Mask::Drop(JsonPath::parse("metadata.creationTimestamp")))
204        .with(Mask::Drop(JsonPath::parse("metadata.selfLink")))
205        // Server-side-apply bookkeeping (engenho stamps it only on apply;
206        // k3s stamps it on every write). The timestamps within are the
207        // volatile part; the whole block is dropped for a clean diff.
208        .with(Mask::Drop(JsonPath::parse("metadata.managedFields")))
209        // Whole status subtree — time-varying by construction.
210        .with(Mask::Drop(JsonPath::parse("status")))
211        // Allocated network identity (Service).
212        .with(Mask::Drop(JsonPath::parse("spec.clusterIP")))
213        .with(Mask::Drop(JsonPath::parse("spec.clusterIPs")))
214        // Scheduler-assigned placement (Pod).
215        .with(Mask::Drop(JsonPath::parse("spec.nodeName")))
216        // List envelope continuation token.
217        .with(Mask::Drop(JsonPath::parse("metadata.continue")))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde_json::json;
224
225    #[test]
226    fn drop_removes_nested_key() {
227        let mut norm = Normalizer::new().with(Mask::Drop(JsonPath::parse("metadata.uid")));
228        let v = json!({"metadata": {"name": "x", "uid": "abc"}});
229        let out = norm.apply(&v);
230        assert_eq!(out, json!({"metadata": {"name": "x"}}));
231        // untouched clone
232        assert!(v["metadata"]["uid"].is_string());
233        norm = norm.with(Mask::Drop(JsonPath::parse("status")));
234        let out2 = norm.apply(&json!({"metadata": {"uid": "z"}, "status": {"phase": "Active"}}));
235        assert_eq!(out2, json!({"metadata": {}}));
236    }
237
238    #[test]
239    fn wildcard_drops_time_in_each_element() {
240        let norm = Normalizer::new().with(Mask::Drop(JsonPath::parse(
241            "metadata.managedFields[*].time",
242        )));
243        let v = json!({"metadata": {"managedFields": [
244            {"manager": "a", "time": "t1"},
245            {"manager": "b", "time": "t2"},
246        ]}});
247        let out = norm.apply(&v);
248        assert_eq!(
249            out,
250            json!({"metadata": {"managedFields": [{"manager": "a"}, {"manager": "b"}]}})
251        );
252    }
253
254    #[test]
255    fn volatile_meta_leaves_finalizers_and_data() {
256        let norm = volatile_meta();
257        let v = json!({
258            "metadata": {"name": "ns", "uid": "u", "finalizers": ["kubernetes"]},
259            "spec": {"finalizers": ["kubernetes"]},
260            "data": {"k": "v"},
261            "status": {"phase": "Active"},
262        });
263        let out = norm.apply(&v);
264        assert!(out["metadata"]["finalizers"].is_array());
265        assert!(out["spec"]["finalizers"].is_array());
266        assert_eq!(out["data"]["k"], json!("v"));
267        assert!(out.get("status").is_none());
268        assert!(out["metadata"].get("uid").is_none());
269    }
270}