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::{
8    COMMENT_VOLATILE_FIELDS, ExceptionItem, ExceptionList, ITEM_VOLATILE_FIELDS,
9    LIST_VOLATILE_FIELDS, Rule, VOLATILE_FIELDS, server_defaults,
10};
11use serde_json::{Map, Value};
12
13/// Remove the server-owned fields that change on every write.
14pub fn strip_volatile(rule: &mut Rule) {
15    let map = rule.as_map_mut();
16    for field in VOLATILE_FIELDS {
17        map.remove(field);
18    }
19}
20
21/// Remove the volatile pointer from every exception reference.
22///
23/// Spec 4.5: `id` is required on create and validated by nothing, while export
24/// and import both match on `list_id`. Identity is `list_id` plus
25/// `namespace_type`; `push` re-resolves `id` against the target stack.
26pub fn strip_exception_ids(rule: &mut Rule) {
27    if let Some(Value::Array(refs)) = rule.as_map_mut().get_mut("exceptions_list") {
28        for r in refs.iter_mut() {
29            if let Value::Object(m) = r {
30                m.remove("id");
31            }
32        }
33    }
34}
35
36/// Add fields the server fills on create without overwriting explicit values.
37pub fn fill_defaults(rule: &mut Rule) {
38    let map = rule.as_map_mut();
39    for (k, v) in server_defaults() {
40        map.entry(k).or_insert(v);
41    }
42}
43
44/// Recursively sort object keys for deterministic output.
45///
46/// `serde_json::Map` uses `BTreeMap` by default. This workspace enables
47/// `preserve_order`, which retains insertion order, so rebuild the map to make
48/// the output order explicit.
49fn sort_value(value: &Value) -> Value {
50    match value {
51        Value::Object(m) => {
52            let mut keys: Vec<&String> = m.keys().collect();
53            keys.sort();
54            let mut out = Map::new();
55            for k in keys {
56                out.insert(k.clone(), sort_value(&m[k]));
57            }
58            Value::Object(out)
59        }
60        // Array order is meaningful for tags, index patterns, and threat
61        // mappings, so preserve it.
62        Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
63        other => other.clone(),
64    }
65}
66
67/// The on-disk form.
68pub fn canonical(rule: &Rule) -> Rule {
69    let mut out = rule.clone();
70    strip_volatile(&mut out);
71    strip_exception_ids(&mut out);
72    let sorted = sort_value(&Value::Object(out.as_map().clone()));
73    Rule::from_value(sorted).expect("rule_id survives normalization")
74}
75
76/// The comparison form.
77pub fn comparable(rule: &Rule) -> Rule {
78    let mut out = rule.clone();
79    strip_volatile(&mut out);
80    strip_exception_ids(&mut out);
81    fill_defaults(&mut out);
82    let sorted = sort_value(&Value::Object(out.as_map().clone()));
83    Rule::from_value(sorted).expect("rule_id survives normalization")
84}
85
86/// The on-disk form of an exception-list container.
87pub fn canonical_list(list: &ExceptionList) -> ExceptionList {
88    let mut out = list.clone();
89    for field in LIST_VOLATILE_FIELDS {
90        out.as_map_mut().remove(field);
91    }
92    let sorted = sort_value(&Value::Object(out.as_map().clone()));
93    ExceptionList::from_value(sorted).expect("list_id survives normalization")
94}
95
96/// The on-disk form of an exception item.
97pub fn canonical_item(item: &ExceptionItem) -> ExceptionItem {
98    let mut out = item.clone();
99    for field in ITEM_VOLATILE_FIELDS {
100        out.as_map_mut().remove(field);
101    }
102    // Comments carry server-minted fields too (spec 7.7). Skip non-object
103    // entries the way `exception_refs` skips malformed reference entries.
104    if let Some(Value::Array(comments)) = out.as_map_mut().get_mut("comments") {
105        for c in comments.iter_mut() {
106            if let Value::Object(m) = c {
107                for field in COMMENT_VOLATILE_FIELDS {
108                    m.remove(field);
109                }
110            }
111        }
112    }
113    let sorted = sort_value(&Value::Object(out.as_map().clone()));
114    ExceptionItem::from_value(sorted).expect("item_id survives normalization")
115}
116
117/// Sort rules for stable file output and diff reports. Rules with unreadable
118/// `rule_id` values sort last.
119pub fn sort_rules(rules: &mut [Rule]) {
120    rules.sort_by(|a, b| {
121        let (x, y) = (
122            a.rule_id().unwrap_or("\u{7f}"),
123            b.rule_id().unwrap_or("\u{7f}"),
124        );
125        x.cmp(y)
126    });
127}
128
129/// Sort exception-list containers for stable file output and diff reports.
130/// Lists with unreadable `list_id` values sort last.
131pub fn sort_lists(lists: &mut [ExceptionList]) {
132    lists.sort_by(|a, b| {
133        let (x, y) = (
134            (a.namespace_type(), a.list_id().unwrap_or("\u{7f}")),
135            (b.namespace_type(), b.list_id().unwrap_or("\u{7f}")),
136        );
137        x.cmp(&y)
138    });
139}
140
141/// Sort exception items for stable file output and diff reports. Items with
142/// unreadable identities sort last.
143pub fn sort_items(items: &mut [ExceptionItem]) {
144    items.sort_by(|a, b| {
145        let (x, y) = (
146            (
147                a.list_id().unwrap_or("\u{7f}"),
148                a.item_id().unwrap_or("\u{7f}"),
149            ),
150            (
151                b.list_id().unwrap_or("\u{7f}"),
152                b.item_id().unwrap_or("\u{7f}"),
153            ),
154        );
155        x.cmp(&y)
156    });
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use serde_json::json;
163
164    fn pulled() -> Rule {
165        // A rule returned by the API.
166        Rule::from_value(json!({
167            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21,
168            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
169            "created_at": "2026-08-12T17:49:01.682Z", "created_by": "key-id",
170            "updated_at": "2026-08-12T17:49:01.682Z", "updated_by": "key-id",
171            "revision": 0, "version": 1,
172            "execution_summary": {"last_execution": {"date": "2026-08-13T03:00:20.804Z"}},
173            "max_signals": 100, "to": "now"
174        }))
175        .unwrap()
176    }
177
178    fn hand_authored() -> Rule {
179        // A hand-authored rule has no volatile fields or defaults.
180        Rule::from_value(json!({
181            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21
182        }))
183        .unwrap()
184    }
185
186    #[test]
187    fn strip_volatile_removes_all_eight_measured_fields() {
188        let mut r = pulled();
189        strip_volatile(&mut r);
190        for f in VOLATILE_FIELDS {
191            assert!(!r.as_map().contains_key(f), "{f} should have been stripped");
192        }
193        assert_eq!(r.rule_id().unwrap(), "abc", "identity must survive");
194        assert_eq!(
195            r.as_map()["max_signals"],
196            json!(100),
197            "non-volatile fields stay"
198        );
199    }
200
201    #[test]
202    fn strip_volatile_is_idempotent() {
203        let mut once = pulled();
204        strip_volatile(&mut once);
205        let mut twice = once.clone();
206        strip_volatile(&mut twice);
207        assert_eq!(once, twice);
208    }
209
210    #[test]
211    fn fill_defaults_adds_only_absent_fields() {
212        let mut r = hand_authored();
213        fill_defaults(&mut r);
214        assert_eq!(r.as_map()["max_signals"], json!(100));
215        assert_eq!(r.as_map()["to"], json!("now"));
216        assert_eq!(
217            r.as_map()["risk_score"],
218            json!(21),
219            "an author's value is never replaced"
220        );
221    }
222
223    #[test]
224    fn fill_defaults_never_overwrites_an_explicit_value() {
225        let mut r = Rule::from_value(json!({"rule_id": "abc", "max_signals": 5000})).unwrap();
226        fill_defaults(&mut r);
227        assert_eq!(r.as_map()["max_signals"], json!(5000));
228    }
229
230    #[test]
231    fn canonical_sorts_keys_so_output_is_deterministic() {
232        let a = Rule::from_value(json!({"rule_id": "x", "zeta": 1, "alpha": 2})).unwrap();
233        let b = Rule::from_value(json!({"rule_id": "x", "alpha": 2, "zeta": 1})).unwrap();
234        let (ca, cb) = (canonical(&a), canonical(&b));
235        assert_eq!(ca, cb, "key order must not affect the canonical form");
236        let keys: Vec<&String> = ca.as_map().keys().collect();
237        let mut sorted = keys.clone();
238        sorted.sort();
239        assert_eq!(keys, sorted);
240    }
241
242    #[test]
243    fn canonical_sorts_nested_object_keys_too() {
244        let r = Rule::from_value(json!({
245            "rule_id": "x", "rule_source": {"zeta": 1, "alpha": 2}
246        }))
247        .unwrap();
248        let c = canonical(&r);
249        let nested = c.as_map()["rule_source"].as_object().unwrap();
250        let keys: Vec<&String> = nested.keys().collect();
251        assert_eq!(keys, vec!["alpha", "zeta"]);
252    }
253
254    #[test]
255    fn canonical_does_not_invent_defaults() {
256        let c = canonical(&hand_authored());
257        assert!(
258            !c.as_map().contains_key("max_signals"),
259            "pull must not bloat files"
260        );
261    }
262
263    // The state engine relies on this property.
264    #[test]
265    fn a_pulled_rule_and_its_hand_authored_equivalent_compare_equal() {
266        assert_eq!(
267            comparable(&pulled()),
268            comparable(&hand_authored()),
269            "a sparse local file must not read as drift against its remote counterpart"
270        );
271    }
272
273    #[test]
274    fn a_real_difference_still_compares_unequal() {
275        let mut changed = hand_authored();
276        changed.as_map_mut().insert("risk_score".into(), json!(99));
277        assert_ne!(comparable(&pulled()), comparable(&changed));
278    }
279
280    #[test]
281    fn sort_rules_orders_by_rule_id() {
282        let mk = |id: &str| Rule::from_value(json!({"rule_id": id})).unwrap();
283        let mut rules = vec![mk("c"), mk("a"), mk("b")];
284        sort_rules(&mut rules);
285        let ids: Vec<&str> = rules.iter().map(|r| r.rule_id().unwrap()).collect();
286        assert_eq!(ids, vec!["a", "b", "c"]);
287    }
288
289    #[test]
290    fn array_order_is_preserved_not_sorted() {
291        let r = Rule::from_value(json!({
292            "rule_id": "x",
293            "tags": ["zebra", "alpha", "middle"],
294            "index": ["logs-b-*", "logs-a-*"],
295            "threat": [{"z": 1}, {"a": 2}]
296        }))
297        .unwrap();
298        let c = canonical(&r);
299        let tags = c.as_map()["tags"].as_array().unwrap();
300        let tag_strs: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
301        assert_eq!(
302            tag_strs,
303            vec!["zebra", "alpha", "middle"],
304            "tag order must be preserved"
305        );
306        let index = c.as_map()["index"].as_array().unwrap();
307        let index_strs: Vec<&str> = index.iter().filter_map(|v| v.as_str()).collect();
308        assert_eq!(
309            index_strs,
310            vec!["logs-b-*", "logs-a-*"],
311            "index order must be preserved"
312        );
313        let threat = c.as_map()["threat"].as_array().unwrap();
314        let first_key = threat[0].as_object().unwrap().keys().next().unwrap();
315        assert_eq!(
316            first_key, "z",
317            "array element order preserved, keys sorted within"
318        );
319    }
320
321    #[test]
322    fn sort_rules_puts_unreadable_rule_id_last_without_panicking() {
323        // Transparent `Deserialize` can create this; `from_value` rejects it.
324        let bad: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
325        let good_c = Rule::from_value(json!({"rule_id": "c"})).unwrap();
326        let good_a = Rule::from_value(json!({"rule_id": "a"})).unwrap();
327        let mut rules = vec![good_c, bad, good_a];
328        sort_rules(&mut rules);
329        assert_eq!(rules[0].rule_id().unwrap(), "a");
330        assert_eq!(rules[1].rule_id().unwrap(), "c");
331        assert!(rules[2].rule_id().is_err(), "unreadable id sorts last");
332    }
333
334    #[test]
335    fn canonical_is_idempotent() {
336        let r = Rule::from_value(json!({
337            "rule_id": "x", "tags": ["z", "a"], "nested": {"z": 1, "a": 2}
338        }))
339        .unwrap();
340        let once = canonical(&r);
341        let twice = canonical(&once);
342        assert_eq!(once, twice);
343    }
344
345    #[test]
346    fn comparable_is_idempotent() {
347        let r = Rule::from_value(json!({
348            "rule_id": "x", "name": "test", "type": "query", "risk_score": 10
349        }))
350        .unwrap();
351        let once = comparable(&r);
352        let twice = comparable(&once);
353        assert_eq!(once, twice);
354    }
355
356    /// Spec 4.5. Identity is `list_id`; `id` is a pointer resolved at push time.
357    #[test]
358    fn canonical_strips_the_exception_pointer_but_keeps_the_reference() {
359        let r = Rule::from_value(json!({
360            "rule_id": "x",
361            "exceptions_list": [{
362                "id": "3724d409-4c0f-4630-a1ef-706499730808",
363                "list_id": "shared", "type": "detection", "namespace_type": "single"
364            }]
365        }))
366        .unwrap();
367        let c = canonical(&r);
368        let refs = c.as_map()["exceptions_list"].as_array().unwrap();
369        assert!(
370            refs[0].get("id").is_none(),
371            "the volatile pointer is stripped"
372        );
373        assert_eq!(refs[0]["list_id"], json!("shared"), "identity survives");
374        assert_eq!(refs[0]["namespace_type"], json!("single"));
375    }
376
377    /// The bug this closes: a rule promoted between stacks carries a pointer to an
378    /// object that does not exist on the target.
379    #[test]
380    fn two_stacks_ids_for_one_list_do_not_read_as_drift() {
381        let mk = |id: &str| {
382            Rule::from_value(json!({
383                "rule_id": "x",
384                "exceptions_list": [{"id": id, "list_id": "shared",
385                                     "type": "detection", "namespace_type": "single"}]
386            }))
387            .unwrap()
388        };
389        assert_eq!(
390            comparable(&mk("id-on-dev")),
391            comparable(&mk("id-on-prod")),
392            "the same list on two stacks must not read as drift"
393        );
394    }
395
396    #[test]
397    fn strip_exception_ids_strips_every_reference_not_just_the_first() {
398        let c = canonical(
399            &Rule::from_value(json!({
400                "rule_id": "x",
401                "exceptions_list": [
402                    {"id": "id-1", "list_id": "one"},
403                    {"id": "id-2", "list_id": "two"}
404                ]
405            }))
406            .unwrap(),
407        );
408        let refs = c.as_map()["exceptions_list"].as_array().unwrap();
409        assert_eq!(refs.len(), 2);
410        for entry in refs {
411            assert!(
412                entry.get("id").is_none(),
413                "every reference loses its pointer"
414            );
415        }
416        assert_eq!(refs[0]["list_id"], json!("one"));
417        assert_eq!(refs[1]["list_id"], json!("two"));
418    }
419
420    #[test]
421    fn a_rule_with_no_exceptions_is_untouched() {
422        let r = Rule::from_value(json!({"rule_id": "x", "name": "X"})).unwrap();
423        assert_eq!(canonical(&r).as_map().get("exceptions_list"), None);
424    }
425
426    #[test]
427    fn strip_exception_ids_is_idempotent() {
428        let mut r = Rule::from_value(json!({
429            "rule_id": "x",
430            "exceptions_list": [{"id": "a", "list_id": "l"}]
431        }))
432        .unwrap();
433        strip_exception_ids(&mut r);
434        let once = r.clone();
435        strip_exception_ids(&mut r);
436        assert_eq!(once, r);
437    }
438
439    #[test]
440    fn strip_exception_ids_skips_non_objects_and_absent_ids() {
441        let mut r = Rule::from_value(json!({
442            "rule_id": "x",
443            "exceptions_list": ["not an object", {"list_id": "already stripped"}]
444        }))
445        .unwrap();
446        strip_exception_ids(&mut r); // must not panic
447        let refs = r.as_map()["exceptions_list"].as_array().unwrap();
448        assert_eq!(
449            refs[0],
450            json!("not an object"),
451            "non-object entries are left alone"
452        );
453        assert_eq!(
454            refs[1],
455            json!({"list_id": "already stripped"}),
456            "an absent id is a no-op"
457        );
458    }
459
460    #[test]
461    fn canonical_list_strips_every_measured_volatile_field() {
462        let l = ExceptionList::from_value(json!({
463            "list_id": "l", "name": "L", "id": "server-id", "_version": "WzUsMV0=",
464            "tie_breaker_id": "tb", "version": 3,
465            "created_at": "2026-08-13T23:38:39.519Z", "created_by": "452295856",
466            "updated_at": "2026-08-13T23:38:39.519Z", "updated_by": "452295856",
467            "meta": {"zeta": 1, "alpha": 2},
468            "os_types": ["linux", "windows"]
469        }))
470        .unwrap();
471        let c = canonical_list(&l);
472        for f in LIST_VOLATILE_FIELDS {
473            assert!(!c.as_map().contains_key(f), "{f} should have been stripped");
474        }
475        assert_eq!(c.list_id().unwrap(), "l", "identity must survive");
476        let nested = c.as_map()["meta"].as_object().unwrap();
477        let keys: Vec<&String> = nested.keys().collect();
478        assert_eq!(keys, vec!["alpha", "zeta"], "nested keys are sorted");
479        let os = c.as_map()["os_types"].as_array().unwrap();
480        let os_strs: Vec<&str> = os.iter().filter_map(|v| v.as_str()).collect();
481        assert_eq!(
482            os_strs,
483            vec!["linux", "windows"],
484            "array order is preserved"
485        );
486    }
487
488    #[test]
489    fn canonical_item_strips_every_measured_volatile_field() {
490        let i = ExceptionItem::from_value(json!({
491            "item_id": "i", "list_id": "l", "name": "I", "id": "server-id",
492            "_version": "WzUsMV0=", "tie_breaker_id": "tb",
493            "created_at": "2026-08-13T23:38:39.519Z", "created_by": "452295856",
494            "updated_at": "2026-08-13T23:38:39.519Z", "updated_by": "452295856",
495            "meta": {"zeta": 1, "alpha": 2},
496            "entries": [{"z": 1}, {"a": 2}]
497        }))
498        .unwrap();
499        let c = canonical_item(&i);
500        for f in ITEM_VOLATILE_FIELDS {
501            assert!(!c.as_map().contains_key(f), "{f} should have been stripped");
502        }
503        assert_eq!(c.item_id().unwrap(), "i", "identity must survive");
504        let nested = c.as_map()["meta"].as_object().unwrap();
505        let keys: Vec<&String> = nested.keys().collect();
506        assert_eq!(keys, vec!["alpha", "zeta"], "nested keys are sorted");
507        let entries = c.as_map()["entries"].as_array().unwrap();
508        let first_key = entries[0].as_object().unwrap().keys().next().unwrap();
509        assert_eq!(
510            first_key, "z",
511            "array element order preserved, keys sorted within"
512        );
513    }
514
515    #[test]
516    fn canonical_item_strips_volatile_fields_inside_comments() {
517        let i = ExceptionItem::from_value(json!({
518            "item_id": "i", "list_id": "l",
519            "comments": [{
520                "id": "0b025f61-b0b9-4658-83cf-cbb581ad2358",
521                "comment": "first note",
522                "created_at": "2026-08-14T04:49:54.101Z",
523                "created_by": "452295856"
524            }]
525        }))
526        .unwrap();
527        let c = canonical_item(&i);
528        let comments = c.as_map()["comments"].as_array().unwrap();
529        let first = comments[0].as_object().unwrap();
530        for f in COMMENT_VOLATILE_FIELDS {
531            assert!(!first.contains_key(f), "{f} should have been stripped");
532        }
533        assert_eq!(
534            comments[0]["comment"],
535            json!("first note"),
536            "the author's text survives"
537        );
538    }
539
540    #[test]
541    fn sort_lists_orders_by_namespace_then_list_id() {
542        let mk = |ns: &str, id: &str| {
543            ExceptionList::from_value(json!({"list_id": id, "namespace_type": ns})).unwrap()
544        };
545        let mut lists = vec![mk("agnostic", "b"), mk("single", "a"), mk("agnostic", "a")];
546        sort_lists(&mut lists);
547        let order: Vec<(&str, &str)> = lists
548            .iter()
549            .map(|l| (l.namespace_type(), l.list_id().unwrap()))
550            .collect();
551        assert_eq!(
552            order,
553            vec![("agnostic", "a"), ("agnostic", "b"), ("single", "a")]
554        );
555    }
556
557    #[test]
558    fn sort_items_orders_by_list_then_item_id() {
559        let mk = |list: &str, item: &str| {
560            ExceptionItem::from_value(json!({"list_id": list, "item_id": item})).unwrap()
561        };
562        let mut items = vec![mk("b", "2"), mk("a", "2"), mk("b", "1")];
563        sort_items(&mut items);
564        let order: Vec<(&str, &str)> = items
565            .iter()
566            .map(|i| (i.list_id().unwrap(), i.item_id().unwrap()))
567            .collect();
568        assert_eq!(order, vec![("a", "2"), ("b", "1"), ("b", "2")]);
569    }
570
571    #[test]
572    fn sort_lists_puts_unreadable_list_id_last_without_panicking() {
573        // Transparent `Deserialize` can create this; `from_value` rejects it.
574        let bad: ExceptionList =
575            serde_json::from_value(json!({"namespace_type": "single"})).unwrap();
576        let good_c =
577            ExceptionList::from_value(json!({"list_id": "c", "namespace_type": "single"})).unwrap();
578        let good_a =
579            ExceptionList::from_value(json!({"list_id": "a", "namespace_type": "single"})).unwrap();
580        let mut lists = vec![good_c, bad, good_a];
581        sort_lists(&mut lists);
582        assert_eq!(lists[0].list_id().unwrap(), "a");
583        assert_eq!(lists[1].list_id().unwrap(), "c");
584        assert!(lists[2].list_id().is_err(), "unreadable list_id sorts last");
585    }
586
587    #[test]
588    fn sort_items_puts_unreadable_list_id_last_without_panicking() {
589        // Transparent `Deserialize` can create this; `from_value` rejects it.
590        let bad: ExceptionItem = serde_json::from_value(json!({"item_id": "i"})).unwrap();
591        let good_c = ExceptionItem::from_value(json!({"list_id": "c", "item_id": "i"})).unwrap();
592        let good_a = ExceptionItem::from_value(json!({"list_id": "a", "item_id": "i"})).unwrap();
593        let mut items = vec![good_c, bad, good_a];
594        sort_items(&mut items);
595        assert_eq!(items[0].list_id().unwrap(), "a");
596        assert_eq!(items[1].list_id().unwrap(), "c");
597        assert!(items[2].list_id().is_err(), "unreadable list_id sorts last");
598    }
599}