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}