Skip to main content

elasticctl_api/
model.rs

1use elasticctl_core::{Error, ErrorKind, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value, json};
4
5pub const VOLATILE_FIELDS: [&str; 8] = [
6    "id",
7    "created_at",
8    "created_by",
9    "updated_at",
10    "updated_by",
11    "revision",
12    "version",
13    "execution_summary",
14];
15
16pub fn server_defaults() -> Map<String, Value> {
17    let mut m = Map::new();
18    m.insert("actions".into(), json!([]));
19    m.insert("author".into(), json!([]));
20    m.insert("exceptions_list".into(), json!([]));
21    m.insert("false_positives".into(), json!([]));
22    m.insert("immutable".into(), json!(false));
23    m.insert("max_signals".into(), json!(100));
24    m.insert("output_index".into(), json!(""));
25    m.insert("references".into(), json!([]));
26    m.insert("related_integrations".into(), json!([]));
27    m.insert("required_fields".into(), json!([]));
28    m.insert("risk_score_mapping".into(), json!([]));
29    m.insert("rule_source".into(), json!({"type": "internal"}));
30    m.insert("setup".into(), json!(""));
31    m.insert("severity_mapping".into(), json!([]));
32    m.insert("threat".into(), json!([]));
33    m.insert("to".into(), json!("now"));
34    m
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(transparent)]
39pub struct Rule(Map<String, Value>);
40
41impl Rule {
42    pub fn from_value(v: Value) -> Result<Rule> {
43        let map = match v {
44            Value::Object(m) => m,
45            _ => return Err(Error::new(ErrorKind::Error, "a rule must be a JSON object")),
46        };
47        match map.get("rule_id") {
48            Some(Value::String(_)) => {}
49            Some(_) => {
50                return Err(Error::new(
51                    ErrorKind::Error,
52                    "a rule's rule_id must be a string",
53                ));
54            }
55            None => return Err(Error::new(ErrorKind::Error, "a rule must have a rule_id")),
56        }
57        Ok(Rule(map))
58    }
59
60    pub fn into_value(self) -> Value {
61        Value::Object(self.0)
62    }
63
64    pub fn as_map(&self) -> &Map<String, Value> {
65        &self.0
66    }
67
68    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
69        &mut self.0
70    }
71
72    fn str_field(&self, key: &str) -> &str {
73        self.0.get(key).and_then(Value::as_str).unwrap_or("")
74    }
75
76    pub fn rule_id(&self) -> Result<&str> {
77        self.0
78            .get("rule_id")
79            .and_then(Value::as_str)
80            .ok_or_else(|| Error::new(ErrorKind::Error, "rule is missing rule_id"))
81    }
82
83    pub fn name(&self) -> &str {
84        self.str_field("name")
85    }
86
87    pub fn rule_type(&self) -> &str {
88        self.str_field("type")
89    }
90
91    pub fn severity(&self) -> &str {
92        self.str_field("severity")
93    }
94
95    pub fn enabled(&self) -> bool {
96        self.0
97            .get("enabled")
98            .and_then(Value::as_bool)
99            .unwrap_or(false)
100    }
101
102    pub fn risk_score(&self) -> i64 {
103        self.0
104            .get("risk_score")
105            .and_then(Value::as_i64)
106            .unwrap_or(0)
107    }
108
109    pub fn tags(&self) -> Vec<&str> {
110        self.0
111            .get("tags")
112            .and_then(Value::as_array)
113            .map(|a| a.iter().filter_map(Value::as_str).collect())
114            .unwrap_or_default()
115    }
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ExportSummary {
120    #[serde(default)]
121    pub exported_count: u64,
122    #[serde(default)]
123    pub exported_rules_count: u64,
124    #[serde(default)]
125    pub missing_rules_count: u64,
126    #[serde(default)]
127    pub missing_rules: Vec<Value>,
128    #[serde(default)]
129    pub exported_exception_list_count: u64,
130    #[serde(default)]
131    pub exported_exception_list_item_count: u64,
132    #[serde(default)]
133    pub missing_exception_lists: Vec<Value>,
134    #[serde(default)]
135    pub missing_exception_list_items: Vec<Value>,
136}
137
138pub const LIST_VOLATILE_FIELDS: [&str; 8] = [
139    "id",
140    "_version",
141    "tie_breaker_id",
142    "version",
143    "created_at",
144    "created_by",
145    "updated_at",
146    "updated_by",
147];
148
149pub const ITEM_VOLATILE_FIELDS: [&str; 7] = [
150    "id",
151    "_version",
152    "tie_breaker_id",
153    "created_at",
154    "created_by",
155    "updated_at",
156    "updated_by",
157];
158
159pub const COMMENT_VOLATILE_FIELDS: [&str; 5] =
160    ["id", "created_at", "created_by", "updated_at", "updated_by"];
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(transparent)]
164pub struct ExceptionList(Map<String, Value>);
165
166impl ExceptionList {
167    pub fn from_value(v: Value) -> Result<ExceptionList> {
168        let map = match v {
169            Value::Object(m) => m,
170            _ => {
171                return Err(Error::new(
172                    ErrorKind::Error,
173                    "an exception list must be a JSON object",
174                ));
175            }
176        };
177        match map.get("list_id") {
178            Some(Value::String(_)) => {}
179            Some(_) => {
180                return Err(Error::new(
181                    ErrorKind::Error,
182                    "an exception list's list_id must be a string",
183                ));
184            }
185            None => {
186                return Err(Error::new(
187                    ErrorKind::Error,
188                    "an exception list must have a list_id",
189                ));
190            }
191        }
192        Ok(ExceptionList(map))
193    }
194
195    pub fn list_id(&self) -> Result<&str> {
196        self.0
197            .get("list_id")
198            .and_then(Value::as_str)
199            .ok_or_else(|| Error::new(ErrorKind::Error, "exception list is missing list_id"))
200    }
201
202    pub fn namespace_type(&self) -> &str {
203        self.0
204            .get("namespace_type")
205            .and_then(Value::as_str)
206            .unwrap_or("single")
207    }
208
209    pub fn list_type(&self) -> &str {
210        self.0.get("type").and_then(Value::as_str).unwrap_or("")
211    }
212
213    pub fn name(&self) -> &str {
214        self.0.get("name").and_then(Value::as_str).unwrap_or("")
215    }
216
217    pub fn tags(&self) -> Vec<&str> {
218        self.0
219            .get("tags")
220            .and_then(Value::as_array)
221            .map(|a| a.iter().filter_map(Value::as_str).collect())
222            .unwrap_or_default()
223    }
224
225    pub fn key(&self) -> Result<ListKey> {
226        Ok(ListKey {
227            list_id: self.list_id()?.to_string(),
228            namespace_type: self.namespace_type().to_string(),
229        })
230    }
231
232    pub fn as_map(&self) -> &Map<String, Value> {
233        &self.0
234    }
235
236    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
237        &mut self.0
238    }
239
240    pub fn into_value(self) -> Value {
241        Value::Object(self.0)
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(transparent)]
247pub struct ExceptionItem(Map<String, Value>);
248
249impl ExceptionItem {
250    pub fn from_value(v: Value) -> Result<ExceptionItem> {
251        let map = match v {
252            Value::Object(m) => m,
253            _ => {
254                return Err(Error::new(
255                    ErrorKind::Error,
256                    "an exception item must be a JSON object",
257                ));
258            }
259        };
260        match map.get("item_id") {
261            Some(Value::String(_)) => {}
262            Some(_) => {
263                return Err(Error::new(
264                    ErrorKind::Error,
265                    "an exception item's item_id must be a string",
266                ));
267            }
268            None => {
269                return Err(Error::new(
270                    ErrorKind::Error,
271                    "an exception item must have an item_id",
272                ));
273            }
274        }
275        Ok(ExceptionItem(map))
276    }
277
278    pub fn item_id(&self) -> Result<&str> {
279        self.0
280            .get("item_id")
281            .and_then(Value::as_str)
282            .ok_or_else(|| Error::new(ErrorKind::Error, "exception item is missing item_id"))
283    }
284
285    pub fn list_id(&self) -> Result<&str> {
286        self.0
287            .get("list_id")
288            .and_then(Value::as_str)
289            .ok_or_else(|| Error::new(ErrorKind::Error, "exception item is missing list_id"))
290    }
291
292    pub fn namespace_type(&self) -> &str {
293        self.0
294            .get("namespace_type")
295            .and_then(Value::as_str)
296            .unwrap_or("single")
297    }
298
299    pub fn as_map(&self) -> &Map<String, Value> {
300        &self.0
301    }
302
303    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
304        &mut self.0
305    }
306
307    pub fn into_value(self) -> Value {
308        Value::Object(self.0)
309    }
310}
311
312#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
313pub struct ListKey {
314    pub list_id: String,
315    pub namespace_type: String,
316}
317
318pub struct ExceptionRef {
319    pub list_id: String,
320    pub namespace_type: String,
321    pub ref_type: String,
322    pub id: Option<String>,
323}
324
325pub fn exception_refs(rule: &Rule) -> Vec<ExceptionRef> {
326    let entries = match rule
327        .as_map()
328        .get("exceptions_list")
329        .and_then(Value::as_array)
330    {
331        Some(a) => a,
332        None => return Vec::new(),
333    };
334    entries
335        .iter()
336        .filter_map(|entry| {
337            let obj = entry.as_object()?;
338            let list_id = obj.get("list_id")?.as_str()?.to_string();
339            Some(ExceptionRef {
340                list_id,
341                namespace_type: obj
342                    .get("namespace_type")
343                    .and_then(Value::as_str)
344                    .unwrap_or("single")
345                    .to_string(),
346                ref_type: obj
347                    .get("type")
348                    .and_then(Value::as_str)
349                    .unwrap_or("")
350                    .to_string(),
351                id: obj.get("id").and_then(Value::as_str).map(str::to_string),
352            })
353        })
354        .collect()
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use serde_json::json;
361
362    fn probe_rule() -> Value {
363        json!({
364            "rule_id": "elasticctl-schema-probe",
365            "name": "elasticctl schema probe",
366            "description": "Temporary rule.",
367            "type": "query",
368            "language": "kuery",
369            "query": "event.category:process",
370            "index": ["logs-*"],
371            "severity": "low",
372            "risk_score": 21,
373            "enabled": false,
374            "from": "now-6m",
375            "interval": "5m",
376            "tags": ["elasticctl", "temporary"],
377            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
378            "created_at": "2026-08-12T17:49:01.682Z",
379            "created_by": "2XTe9p8BLjNicQlhfc9W",
380            "updated_at": "2026-08-12T17:49:01.682Z",
381            "updated_by": "2XTe9p8BLjNicQlhfc9W",
382            "revision": 0,
383            "version": 1,
384            "max_signals": 100,
385            "to": "now",
386            "rule_source": {"type": "internal"}
387        })
388    }
389
390    #[test]
391    fn accessors_read_the_measured_fields() {
392        let r = Rule::from_value(probe_rule()).unwrap();
393        assert_eq!(r.rule_id().unwrap(), "elasticctl-schema-probe");
394        assert_eq!(r.name(), "elasticctl schema probe");
395        assert_eq!(r.rule_type(), "query");
396        assert!(!r.enabled());
397        assert_eq!(r.severity(), "low");
398        assert_eq!(r.risk_score(), 21);
399        assert_eq!(r.tags(), vec!["elasticctl", "temporary"]);
400    }
401
402    #[test]
403    fn every_unknown_field_survives_a_round_trip() {
404        let original = probe_rule();
405        let r = Rule::from_value(original.clone()).unwrap();
406        assert_eq!(r.into_value(), original, "no field may be dropped");
407    }
408
409    #[test]
410    fn a_rule_without_rule_id_is_rejected() {
411        let err = Rule::from_value(json!({"name": "x"})).unwrap_err();
412        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
413        assert!(err.message.contains("rule_id"));
414    }
415
416    #[test]
417    fn a_non_string_rule_id_is_rejected() {
418        let err = Rule::from_value(json!({"rule_id": 123, "name": "x"})).unwrap_err();
419        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
420        assert!(err.message.contains("string"), "{}", err.message);
421    }
422
423    #[test]
424    fn a_null_rule_id_is_rejected() {
425        assert!(Rule::from_value(json!({"rule_id": null})).is_err());
426    }
427
428    #[test]
429    fn deserialize_bypasses_the_construction_check() {
430        let r: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
431        assert!(r.rule_id().is_err());
432    }
433
434    #[test]
435    fn a_non_object_is_rejected() {
436        assert!(Rule::from_value(json!(["not", "an", "object"])).is_err());
437    }
438
439    #[test]
440    fn missing_optional_fields_read_as_sane_defaults() {
441        let r = Rule::from_value(json!({"rule_id": "x"})).unwrap();
442        assert_eq!(r.name(), "");
443        assert_eq!(r.rule_type(), "");
444        assert!(!r.enabled());
445        assert_eq!(r.risk_score(), 0);
446        assert!(r.tags().is_empty());
447    }
448
449    #[test]
450    fn volatile_field_list_matches_the_measured_set() {
451        let mut got = VOLATILE_FIELDS.to_vec();
452        got.sort_unstable();
453        assert_eq!(
454            got,
455            [
456                "created_at",
457                "created_by",
458                "execution_summary",
459                "id",
460                "revision",
461                "updated_at",
462                "updated_by",
463                "version"
464            ]
465        );
466    }
467
468    #[test]
469    fn server_defaults_cover_the_sixteen_measured_fields() {
470        let d = server_defaults();
471        assert_eq!(d.len(), 16);
472        assert_eq!(d["max_signals"], json!(100));
473        assert_eq!(d["to"], json!("now"));
474        assert_eq!(d["actions"], json!([]));
475        assert_eq!(d["rule_source"], json!({"type": "internal"}));
476        assert_eq!(d["immutable"], json!(false));
477        assert_eq!(d["setup"], json!(""));
478        assert_eq!(d["output_index"], json!(""));
479    }
480
481    #[test]
482    fn volatile_and_default_field_sets_do_not_overlap() {
483        for v in VOLATILE_FIELDS {
484            assert!(!server_defaults().contains_key(v), "{v} is in both sets");
485        }
486    }
487
488    fn probe_list() -> Value {
489        json!({
490            "id": "3724d409-4c0f-4630-a1ef-706499730808",
491            "list_id": "elasticctl-sample-exceptions",
492            "type": "detection",
493            "name": "elasticctl sample exceptions",
494            "description": "elasticctl sample exception list",
495            "immutable": false,
496            "namespace_type": "single",
497            "os_types": [],
498            "tags": ["elasticctl-sample"],
499            "version": 1,
500            "_version": "WzU3NDksMV0=",
501            "tie_breaker_id": "100fd2bc-b559-4c7f-9838-ef9b195c4369",
502            "created_at": "2026-08-13T23:38:39.519Z",
503            "created_by": "452295856",
504            "updated_at": "2026-08-13T23:38:39.519Z",
505            "updated_by": "452295856"
506        })
507    }
508
509    #[test]
510    fn a_list_reads_its_identity_and_keeps_unknown_fields() {
511        let l = ExceptionList::from_value(probe_list()).unwrap();
512        assert_eq!(l.list_id().unwrap(), "elasticctl-sample-exceptions");
513        assert_eq!(l.namespace_type(), "single");
514        assert_eq!(l.list_type(), "detection");
515        assert_eq!(l.tags(), vec!["elasticctl-sample"]);
516        assert_eq!(
517            l.clone().into_value(),
518            probe_list(),
519            "no field may be dropped"
520        );
521    }
522
523    #[test]
524    fn a_list_without_list_id_is_rejected() {
525        let err = ExceptionList::from_value(json!({"name": "x"})).unwrap_err();
526        assert!(err.message.contains("list_id"), "{}", err.message);
527    }
528
529    #[test]
530    fn namespace_type_defaults_to_single_when_absent() {
531        let l = ExceptionList::from_value(json!({"list_id": "x"})).unwrap();
532        assert_eq!(
533            l.namespace_type(),
534            "single",
535            "the API omits it on some responses; identity must still resolve"
536        );
537    }
538
539    #[test]
540    fn list_volatile_fields_match_the_measured_set() {
541        let mut got = LIST_VOLATILE_FIELDS.to_vec();
542        got.sort_unstable();
543        assert_eq!(
544            got,
545            [
546                "_version",
547                "created_at",
548                "created_by",
549                "id",
550                "tie_breaker_id",
551                "updated_at",
552                "updated_by",
553                "version"
554            ]
555        );
556    }
557
558    #[test]
559    fn item_volatile_fields_match_the_measured_set() {
560        let mut got = ITEM_VOLATILE_FIELDS.to_vec();
561        got.sort_unstable();
562        assert_eq!(
563            got,
564            [
565                "_version",
566                "created_at",
567                "created_by",
568                "id",
569                "tie_breaker_id",
570                "updated_at",
571                "updated_by"
572            ]
573        );
574    }
575
576    #[test]
577    fn exception_refs_reads_the_measured_reference_shape() {
578        let r = Rule::from_value(json!({
579            "rule_id": "x",
580            "exceptions_list": [{
581                "id": "3724d409-4c0f-4630-a1ef-706499730808",
582                "list_id": "elasticctl-sample-exceptions",
583                "type": "detection",
584                "namespace_type": "single"
585            }]
586        }))
587        .unwrap();
588        let refs = exception_refs(&r);
589        assert_eq!(refs.len(), 1);
590        assert_eq!(refs[0].list_id, "elasticctl-sample-exceptions");
591        assert_eq!(
592            refs[0].id.as_deref(),
593            Some("3724d409-4c0f-4630-a1ef-706499730808")
594        );
595    }
596
597    #[test]
598    fn exception_refs_skips_a_malformed_entry_without_failing() {
599        let r = Rule::from_value(json!({
600            "rule_id": "x",
601            "exceptions_list": ["not an object", {"list_id": "good"}]
602        }))
603        .unwrap();
604        let refs = exception_refs(&r);
605        assert_eq!(refs.len(), 1, "the readable entry survives");
606        assert_eq!(refs[0].list_id, "good");
607    }
608}