Skip to main content

elasticctl_api/
normalize.rs

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