1use 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
13pub 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
21pub 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
36pub 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
44fn 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 Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
63 other => other.clone(),
64 }
65}
66
67pub 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
76pub 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
86pub 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
96pub 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 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
117pub 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
129pub 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
141pub 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 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 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 #[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 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 #[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 #[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); 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 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 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}