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