Skip to main content

elasticctl_api/
normalize.rs

1//! Deterministic rule forms.
2//!
3//! `canonical` is what `pull` writes: volatile fields removed and keys sorted.
4//! `comparable` is what `diff` compares: `canonical` plus server defaults.
5//! They remain separate so `pull` does not write defaults the author omitted.
6
7use crate::model::{Rule, VOLATILE_FIELDS, server_defaults};
8use serde_json::{Map, Value};
9
10/// Remove the server-owned fields that change on every write.
11pub fn strip_volatile(rule: &mut Rule) {
12    let map = rule.as_map_mut();
13    for field in VOLATILE_FIELDS {
14        map.remove(field);
15    }
16}
17
18/// Add fields the server fills on create without overwriting explicit values.
19pub fn fill_defaults(rule: &mut Rule) {
20    let map = rule.as_map_mut();
21    for (k, v) in server_defaults() {
22        map.entry(k).or_insert(v);
23    }
24}
25
26/// Recursively sort object keys for deterministic output.
27///
28/// `serde_json::Map` currently uses `BTreeMap`, but `preserve_order` makes it
29/// retain insertion order. Rebuild the map so output order does not depend on
30/// a transitive feature.
31fn sort_value(value: &Value) -> Value {
32    match value {
33        Value::Object(m) => {
34            let mut keys: Vec<&String> = m.keys().collect();
35            keys.sort();
36            let mut out = Map::new();
37            for k in keys {
38                out.insert(k.clone(), sort_value(&m[k]));
39            }
40            Value::Object(out)
41        }
42        // Array order is meaningful for tags, index patterns, and threat
43        // mappings, so preserve it.
44        Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
45        other => other.clone(),
46    }
47}
48
49/// The on-disk form.
50pub fn canonical(rule: &Rule) -> Rule {
51    let mut out = rule.clone();
52    strip_volatile(&mut out);
53    let sorted = sort_value(&Value::Object(out.as_map().clone()));
54    Rule::from_value(sorted).expect("rule_id survives normalization")
55}
56
57/// The comparison form.
58pub fn comparable(rule: &Rule) -> Rule {
59    let mut out = rule.clone();
60    strip_volatile(&mut out);
61    fill_defaults(&mut out);
62    let sorted = sort_value(&Value::Object(out.as_map().clone()));
63    Rule::from_value(sorted).expect("rule_id survives normalization")
64}
65
66/// Sort rules for stable file output and diff reports. Rules with unreadable
67/// `rule_id` values sort last.
68pub fn sort_rules(rules: &mut [Rule]) {
69    rules.sort_by(|a, b| {
70        let (x, y) = (
71            a.rule_id().unwrap_or("\u{7f}"),
72            b.rule_id().unwrap_or("\u{7f}"),
73        );
74        x.cmp(y)
75    });
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use serde_json::json;
82
83    fn pulled() -> Rule {
84        // A rule returned by the API.
85        Rule::from_value(json!({
86            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21,
87            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
88            "created_at": "2026-08-12T17:49:01.682Z", "created_by": "key-id",
89            "updated_at": "2026-08-12T17:49:01.682Z", "updated_by": "key-id",
90            "revision": 0, "version": 1,
91            "execution_summary": {"last_execution": {"date": "2026-08-13T03:00:20.804Z"}},
92            "max_signals": 100, "to": "now"
93        }))
94        .unwrap()
95    }
96
97    fn hand_authored() -> Rule {
98        // A hand-authored rule has no volatile fields or defaults.
99        Rule::from_value(json!({
100            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21
101        }))
102        .unwrap()
103    }
104
105    #[test]
106    fn strip_volatile_removes_all_eight_measured_fields() {
107        let mut r = pulled();
108        strip_volatile(&mut r);
109        for f in VOLATILE_FIELDS {
110            assert!(!r.as_map().contains_key(f), "{f} should have been stripped");
111        }
112        assert_eq!(r.rule_id().unwrap(), "abc", "identity must survive");
113        assert_eq!(
114            r.as_map()["max_signals"],
115            json!(100),
116            "non-volatile fields stay"
117        );
118    }
119
120    #[test]
121    fn strip_volatile_is_idempotent() {
122        let mut once = pulled();
123        strip_volatile(&mut once);
124        let mut twice = once.clone();
125        strip_volatile(&mut twice);
126        assert_eq!(once, twice);
127    }
128
129    #[test]
130    fn fill_defaults_adds_only_absent_fields() {
131        let mut r = hand_authored();
132        fill_defaults(&mut r);
133        assert_eq!(r.as_map()["max_signals"], json!(100));
134        assert_eq!(r.as_map()["to"], json!("now"));
135        assert_eq!(
136            r.as_map()["risk_score"],
137            json!(21),
138            "an author's value is never replaced"
139        );
140    }
141
142    #[test]
143    fn fill_defaults_never_overwrites_an_explicit_value() {
144        let mut r = Rule::from_value(json!({"rule_id": "abc", "max_signals": 5000})).unwrap();
145        fill_defaults(&mut r);
146        assert_eq!(r.as_map()["max_signals"], json!(5000));
147    }
148
149    #[test]
150    fn canonical_sorts_keys_so_output_is_deterministic() {
151        let a = Rule::from_value(json!({"rule_id": "x", "zeta": 1, "alpha": 2})).unwrap();
152        let b = Rule::from_value(json!({"rule_id": "x", "alpha": 2, "zeta": 1})).unwrap();
153        let (ca, cb) = (canonical(&a), canonical(&b));
154        assert_eq!(ca, cb, "key order must not affect the canonical form");
155        let keys: Vec<&String> = ca.as_map().keys().collect();
156        let mut sorted = keys.clone();
157        sorted.sort();
158        assert_eq!(keys, sorted);
159    }
160
161    #[test]
162    fn canonical_sorts_nested_object_keys_too() {
163        let r = Rule::from_value(json!({
164            "rule_id": "x", "rule_source": {"zeta": 1, "alpha": 2}
165        }))
166        .unwrap();
167        let c = canonical(&r);
168        let nested = c.as_map()["rule_source"].as_object().unwrap();
169        let keys: Vec<&String> = nested.keys().collect();
170        assert_eq!(keys, vec!["alpha", "zeta"]);
171    }
172
173    #[test]
174    fn canonical_does_not_invent_defaults() {
175        let c = canonical(&hand_authored());
176        assert!(
177            !c.as_map().contains_key("max_signals"),
178            "pull must not bloat files"
179        );
180    }
181
182    // The state engine relies on this property.
183    #[test]
184    fn a_pulled_rule_and_its_hand_authored_equivalent_compare_equal() {
185        assert_eq!(
186            comparable(&pulled()),
187            comparable(&hand_authored()),
188            "a sparse local file must not read as drift against its remote counterpart"
189        );
190    }
191
192    #[test]
193    fn a_real_difference_still_compares_unequal() {
194        let mut changed = hand_authored();
195        changed.as_map_mut().insert("risk_score".into(), json!(99));
196        assert_ne!(comparable(&pulled()), comparable(&changed));
197    }
198
199    #[test]
200    fn sort_rules_orders_by_rule_id() {
201        let mk = |id: &str| Rule::from_value(json!({"rule_id": id})).unwrap();
202        let mut rules = vec![mk("c"), mk("a"), mk("b")];
203        sort_rules(&mut rules);
204        let ids: Vec<&str> = rules.iter().map(|r| r.rule_id().unwrap()).collect();
205        assert_eq!(ids, vec!["a", "b", "c"]);
206    }
207
208    #[test]
209    fn array_order_is_preserved_not_sorted() {
210        let r = Rule::from_value(json!({
211            "rule_id": "x",
212            "tags": ["zebra", "alpha", "middle"],
213            "index": ["logs-b-*", "logs-a-*"],
214            "threat": [{"z": 1}, {"a": 2}]
215        }))
216        .unwrap();
217        let c = canonical(&r);
218        let tags = c.as_map()["tags"].as_array().unwrap();
219        let tag_strs: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
220        assert_eq!(
221            tag_strs,
222            vec!["zebra", "alpha", "middle"],
223            "tag order must be preserved"
224        );
225        let index = c.as_map()["index"].as_array().unwrap();
226        let index_strs: Vec<&str> = index.iter().filter_map(|v| v.as_str()).collect();
227        assert_eq!(
228            index_strs,
229            vec!["logs-b-*", "logs-a-*"],
230            "index order must be preserved"
231        );
232        let threat = c.as_map()["threat"].as_array().unwrap();
233        let first_key = threat[0].as_object().unwrap().keys().next().unwrap();
234        assert_eq!(
235            first_key, "z",
236            "array element order preserved, keys sorted within"
237        );
238    }
239
240    #[test]
241    fn sort_rules_puts_unreadable_rule_id_last_without_panicking() {
242        // Transparent `Deserialize` can create this; `from_value` rejects it.
243        let bad: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
244        let good_c = Rule::from_value(json!({"rule_id": "c"})).unwrap();
245        let good_a = Rule::from_value(json!({"rule_id": "a"})).unwrap();
246        let mut rules = vec![good_c, bad, good_a];
247        sort_rules(&mut rules);
248        assert_eq!(rules[0].rule_id().unwrap(), "a");
249        assert_eq!(rules[1].rule_id().unwrap(), "c");
250        assert!(rules[2].rule_id().is_err(), "unreadable id sorts last");
251    }
252
253    #[test]
254    fn canonical_is_idempotent() {
255        let r = Rule::from_value(json!({
256            "rule_id": "x", "tags": ["z", "a"], "nested": {"z": 1, "a": 2}
257        }))
258        .unwrap();
259        let once = canonical(&r);
260        let twice = canonical(&once);
261        assert_eq!(once, twice);
262    }
263
264    #[test]
265    fn comparable_is_idempotent() {
266        let r = Rule::from_value(json!({
267            "rule_id": "x", "name": "test", "type": "query", "risk_score": 10
268        }))
269        .unwrap();
270        let once = comparable(&r);
271        let twice = comparable(&once);
272        assert_eq!(once, twice);
273    }
274}