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                    && *i < a.len()
81                {
82                    a.remove(*i);
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).
194///
195/// `status` is **collapsed to a presence marker, not dropped** (corrected
196/// 2026-08-08). Its contents are genuinely time-varying, so they stay out of
197/// the diff; but dropping the subtree outright made the harness unable to see
198/// that engenho returns **no `status` at all** — its single largest
199/// divergence. A mask that hides the biggest gap is not a mask, it is a
200/// blindfold. Strict mode still re-surfaces what any mask suppressed as
201/// `Cosmetic`.
202#[must_use]
203pub fn volatile_meta() -> Normalizer {
204    Normalizer::new()
205        // Identity / bookkeeping metadata — opaque + server-assigned.
206        .with(Mask::Drop(JsonPath::parse("metadata.uid")))
207        .with(Mask::Drop(JsonPath::parse("metadata.resourceVersion")))
208        .with(Mask::Drop(JsonPath::parse("metadata.generation")))
209        .with(Mask::Drop(JsonPath::parse("metadata.creationTimestamp")))
210        .with(Mask::Drop(JsonPath::parse("metadata.selfLink")))
211        // Server-side-apply bookkeeping (engenho stamps it only on apply;
212        // k3s stamps it on every write). The timestamps within are the
213        // volatile part; the whole block is dropped for a clean diff.
214        .with(Mask::Drop(JsonPath::parse("metadata.managedFields")))
215        // Status: COLLAPSED to a presence marker, never dropped.
216        //
217        // This used to be `Mask::Drop(status)` with the note "time-varying by
218        // construction". The volatility is real — podIP, conditions,
219        // lastTransitionTime and phase all move under you — but dropping the
220        // whole subtree made the harness blind to the single largest
221        // divergence engenho has: **it returns no `status` key at all**
222        // (measured 2026-08-08: a created Pod's top-level keys are
223        // [apiVersion, kind, metadata, spec] against a conformant server's
224        // [apiVersion, kind, metadata, spec, status]).
225        //
226        // An instrument that cannot see the biggest gap is not an instrument.
227        // Collapsing to a constant keeps every volatile leaf out of the diff
228        // while leaving PRESENCE observable: both sides having a status is
229        // parity, one side missing it is a divergence.
230        .with(Mask::Canonicalize(JsonPath::parse("status"), |_| {
231            Value::String("<present>".to_owned())
232        }))
233        // Allocated network identity (Service).
234        .with(Mask::Drop(JsonPath::parse("spec.clusterIP")))
235        .with(Mask::Drop(JsonPath::parse("spec.clusterIPs")))
236        // Scheduler-assigned placement (Pod).
237        .with(Mask::Drop(JsonPath::parse("spec.nodeName")))
238        // List envelope continuation token.
239        .with(Mask::Drop(JsonPath::parse("metadata.continue")))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use serde_json::json;
246
247    #[test]
248    fn drop_removes_nested_key() {
249        let mut norm = Normalizer::new().with(Mask::Drop(JsonPath::parse("metadata.uid")));
250        let v = json!({"metadata": {"name": "x", "uid": "abc"}});
251        let out = norm.apply(&v);
252        assert_eq!(out, json!({"metadata": {"name": "x"}}));
253        // untouched clone
254        assert!(v["metadata"]["uid"].is_string());
255        norm = norm.with(Mask::Drop(JsonPath::parse("status")));
256        let out2 = norm.apply(&json!({"metadata": {"uid": "z"}, "status": {"phase": "Active"}}));
257        assert_eq!(out2, json!({"metadata": {}}));
258    }
259
260    #[test]
261    fn wildcard_drops_time_in_each_element() {
262        let norm = Normalizer::new().with(Mask::Drop(JsonPath::parse(
263            "metadata.managedFields[*].time",
264        )));
265        let v = json!({"metadata": {"managedFields": [
266            {"manager": "a", "time": "t1"},
267            {"manager": "b", "time": "t2"},
268        ]}});
269        let out = norm.apply(&v);
270        assert_eq!(
271            out,
272            json!({"metadata": {"managedFields": [{"manager": "a"}, {"manager": "b"}]}})
273        );
274    }
275
276    #[test]
277    fn volatile_meta_leaves_finalizers_and_data() {
278        let norm = volatile_meta();
279        let v = json!({
280            "metadata": {"name": "ns", "uid": "u", "finalizers": ["kubernetes"]},
281            "spec": {"finalizers": ["kubernetes"]},
282            "data": {"k": "v"},
283            "status": {"phase": "Active"},
284        });
285        let out = norm.apply(&v);
286        assert!(out["metadata"]["finalizers"].is_array());
287        assert!(out["spec"]["finalizers"].is_array());
288        assert_eq!(out["data"]["k"], json!("v"));
289        // Status is COLLAPSED, not dropped: its volatile contents are gone but
290        // its PRESENCE survives, so "engenho returned no status at all" is a
291        // divergence the harness can see. Asserting `is_none()` here was the
292        // unit-test half of the blindness — it pinned the very behaviour that
293        // made the largest gap unobservable.
294        assert_eq!(
295            out["status"],
296            json!("<present>"),
297            "a present status must collapse to the marker, never vanish",
298        );
299        assert!(out["metadata"].get("uid").is_none());
300    }
301
302    /// The point of the collapse, stated as a test: an object WITHOUT a status
303    /// must not normalize to the same value as one WITH a status.
304    ///
305    /// Measured 2026-08-08: engenho returns `[apiVersion, kind, metadata,
306    /// spec]` for a created Pod where a conformant server returns those plus
307    /// `status`. Under the old `Mask::Drop("status")` both sides normalized
308    /// identically and the harness reported parity — it could not have failed.
309    #[test]
310    fn a_missing_status_is_distinguishable_from_a_present_one() {
311        let norm = volatile_meta();
312        let with = norm.apply(&json!({"kind":"Pod","status":{"phase":"Running"}}));
313        let without = norm.apply(&json!({"kind":"Pod"}));
314        assert_ne!(
315            with, without,
316            "an absent status MUST diverge from a present one — this is the \
317             single largest engenho gap and the harness has to be able to see it",
318        );
319        assert!(without.get("status").is_none());
320    }
321}