Skip to main content

elasticctl_api/
model.rs

1//! The canonical rule representation.
2//!
3//! A measured create response has 36 fields, which vary by rule type and
4//! Elastic version. A JSON map preserves unknown fields; a fixed struct would
5//! break round trips.
6
7use elasticctl_core::{Error, ErrorKind, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value, json};
10
11/// Server-owned fields that change on every write or execution. They are
12/// stripped before diffing to avoid false drift.
13pub const VOLATILE_FIELDS: [&str; 8] = [
14    "id",
15    "created_at",
16    "created_by",
17    "updated_at",
18    "updated_by",
19    "revision",
20    "version",
21    "execution_summary",
22];
23
24/// Fields the server fills when a create request omits them.
25///
26/// Measured: a 13-field create returned 36 fields. Fill these defaults before
27/// comparison so omitted values do not appear as drift.
28pub fn server_defaults() -> Map<String, Value> {
29    let mut m = Map::new();
30    m.insert("actions".into(), json!([]));
31    m.insert("author".into(), json!([]));
32    m.insert("exceptions_list".into(), json!([]));
33    m.insert("false_positives".into(), json!([]));
34    m.insert("immutable".into(), json!(false));
35    m.insert("max_signals".into(), json!(100));
36    m.insert("output_index".into(), json!(""));
37    m.insert("references".into(), json!([]));
38    m.insert("related_integrations".into(), json!([]));
39    m.insert("required_fields".into(), json!([]));
40    m.insert("risk_score_mapping".into(), json!([]));
41    m.insert("rule_source".into(), json!({"type": "internal"}));
42    m.insert("setup".into(), json!(""));
43    m.insert("severity_mapping".into(), json!([]));
44    m.insert("threat".into(), json!([]));
45    m.insert("to".into(), json!("now"));
46    m
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(transparent)]
51pub struct Rule(Map<String, Value>);
52
53impl Rule {
54    pub fn from_value(v: Value) -> Result<Rule> {
55        let map = match v {
56            Value::Object(m) => m,
57            _ => return Err(Error::new(ErrorKind::Error, "a rule must be a JSON object")),
58        };
59        // Validate identity at the shared construction path. A non-string
60        // `rule_id` cannot match, name a file, or be reported.
61        match map.get("rule_id") {
62            Some(Value::String(_)) => {}
63            Some(_) => {
64                return Err(Error::new(
65                    ErrorKind::Error,
66                    "a rule's rule_id must be a string",
67                ));
68            }
69            None => return Err(Error::new(ErrorKind::Error, "a rule must have a rule_id")),
70        }
71        Ok(Rule(map))
72    }
73
74    pub fn into_value(self) -> Value {
75        Value::Object(self.0)
76    }
77
78    pub fn as_map(&self) -> &Map<String, Value> {
79        &self.0
80    }
81
82    pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
83        &mut self.0
84    }
85
86    fn str_field(&self, key: &str) -> &str {
87        self.0.get(key).and_then(Value::as_str).unwrap_or("")
88    }
89
90    /// The stable identity used for state matching.
91    ///
92    /// `from_value` requires a string `rule_id`, but transparent `Deserialize`
93    /// and `as_map_mut` can bypass that validation. Return an error rather than
94    /// silently matching the wrong remote rule.
95    pub fn rule_id(&self) -> Result<&str> {
96        self.0
97            .get("rule_id")
98            .and_then(Value::as_str)
99            .ok_or_else(|| Error::new(ErrorKind::Error, "rule is missing rule_id"))
100    }
101
102    pub fn name(&self) -> &str {
103        self.str_field("name")
104    }
105
106    pub fn rule_type(&self) -> &str {
107        self.str_field("type")
108    }
109
110    pub fn severity(&self) -> &str {
111        self.str_field("severity")
112    }
113
114    pub fn enabled(&self) -> bool {
115        self.0
116            .get("enabled")
117            .and_then(Value::as_bool)
118            .unwrap_or(false)
119    }
120
121    pub fn risk_score(&self) -> i64 {
122        self.0
123            .get("risk_score")
124            .and_then(Value::as_i64)
125            .unwrap_or(0)
126    }
127
128    pub fn tags(&self) -> Vec<&str> {
129        self.0
130            .get("tags")
131            .and_then(Value::as_array)
132            .map(|a| a.iter().filter_map(Value::as_str).collect())
133            .unwrap_or_default()
134    }
135}
136
137/// The trailer Kibana appends to an NDJSON export. It is the entire body for
138/// a zero-rule export, so it must not be parsed as a rule.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct ExportSummary {
141    #[serde(default)]
142    pub exported_count: u64,
143    #[serde(default)]
144    pub exported_rules_count: u64,
145    #[serde(default)]
146    pub missing_rules_count: u64,
147    /// Rules selected for export but not returned, likely deleted after
148    /// selection. Kept as raw server values.
149    #[serde(default)]
150    pub missing_rules: Vec<Value>,
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use serde_json::json;
157
158    fn probe_rule() -> Value {
159        // Trimmed from a Serverless Security 9.6.0 create response.
160        json!({
161            "rule_id": "elasticctl-schema-probe",
162            "name": "elasticctl schema probe",
163            "description": "Temporary rule.",
164            "type": "query",
165            "language": "kuery",
166            "query": "event.category:process",
167            "index": ["logs-*"],
168            "severity": "low",
169            "risk_score": 21,
170            "enabled": false,
171            "from": "now-6m",
172            "interval": "5m",
173            "tags": ["elasticctl", "temporary"],
174            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
175            "created_at": "2026-08-12T17:49:01.682Z",
176            "created_by": "2XTe9p8BLjNicQlhfc9W",
177            "updated_at": "2026-08-12T17:49:01.682Z",
178            "updated_by": "2XTe9p8BLjNicQlhfc9W",
179            "revision": 0,
180            "version": 1,
181            "max_signals": 100,
182            "to": "now",
183            "rule_source": {"type": "internal"}
184        })
185    }
186
187    #[test]
188    fn accessors_read_the_measured_fields() {
189        let r = Rule::from_value(probe_rule()).unwrap();
190        assert_eq!(r.rule_id().unwrap(), "elasticctl-schema-probe");
191        assert_eq!(r.name(), "elasticctl schema probe");
192        assert_eq!(r.rule_type(), "query");
193        assert!(!r.enabled());
194        assert_eq!(r.severity(), "low");
195        assert_eq!(r.risk_score(), 21);
196        assert_eq!(r.tags(), vec!["elasticctl", "temporary"]);
197    }
198
199    #[test]
200    fn every_unknown_field_survives_a_round_trip() {
201        let original = probe_rule();
202        let r = Rule::from_value(original.clone()).unwrap();
203        assert_eq!(r.into_value(), original, "no field may be dropped");
204    }
205
206    #[test]
207    fn a_rule_without_rule_id_is_rejected() {
208        let err = Rule::from_value(json!({"name": "x"})).unwrap_err();
209        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
210        assert!(err.message.contains("rule_id"));
211    }
212
213    #[test]
214    fn a_non_string_rule_id_is_rejected() {
215        // State matching requires a readable identity, so construction rejects
216        // non-string `rule_id` values.
217        let err = Rule::from_value(json!({"rule_id": 123, "name": "x"})).unwrap_err();
218        assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
219        assert!(err.message.contains("string"), "{}", err.message);
220    }
221
222    #[test]
223    fn a_null_rule_id_is_rejected() {
224        // `rule_id: null` has no identity and must fail like a numeric value.
225        assert!(Rule::from_value(json!({"rule_id": null})).is_err());
226    }
227
228    /// `from_value` validates construction, but transparent `Deserialize`
229    /// bypasses that validation. Read sites must still handle unreadable IDs.
230    #[test]
231    fn deserialize_bypasses_the_construction_check() {
232        let r: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
233        assert!(r.rule_id().is_err());
234    }
235
236    #[test]
237    fn a_non_object_is_rejected() {
238        assert!(Rule::from_value(json!(["not", "an", "object"])).is_err());
239    }
240
241    #[test]
242    fn missing_optional_fields_read_as_sane_defaults() {
243        let r = Rule::from_value(json!({"rule_id": "x"})).unwrap();
244        assert_eq!(r.name(), "");
245        assert_eq!(r.rule_type(), "");
246        assert!(!r.enabled());
247        assert_eq!(r.risk_score(), 0);
248        assert!(r.tags().is_empty());
249    }
250
251    #[test]
252    fn volatile_field_list_matches_the_measured_set() {
253        let mut got = VOLATILE_FIELDS.to_vec();
254        got.sort_unstable();
255        assert_eq!(
256            got,
257            [
258                "created_at",
259                "created_by",
260                "execution_summary",
261                "id",
262                "revision",
263                "updated_at",
264                "updated_by",
265                "version"
266            ]
267        );
268    }
269
270    #[test]
271    fn server_defaults_cover_the_sixteen_measured_fields() {
272        let d = server_defaults();
273        assert_eq!(d.len(), 16);
274        assert_eq!(d["max_signals"], json!(100));
275        assert_eq!(d["to"], json!("now"));
276        assert_eq!(d["actions"], json!([]));
277        assert_eq!(d["rule_source"], json!({"type": "internal"}));
278        assert_eq!(d["immutable"], json!(false));
279        assert_eq!(d["setup"], json!(""));
280        assert_eq!(d["output_index"], json!(""));
281    }
282
283    #[test]
284    fn volatile_and_default_field_sets_do_not_overlap() {
285        // A field cannot be both stripped and filled.
286        for v in VOLATILE_FIELDS {
287            assert!(!server_defaults().contains_key(v), "{v} is in both sets");
288        }
289    }
290}