Skip to main content

elasticctl_api/
codec.rs

1use crate::model::{ExceptionItem, ExceptionList, ExportSummary, Rule};
2use elasticctl_core::{Error, ErrorKind, Result};
3use serde_json::Value;
4use std::path::Path;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Format {
8    Ndjson,
9    Yaml,
10}
11
12impl Format {
13    pub fn from_path(p: &Path) -> Format {
14        match p.extension().and_then(|e| e.to_str()) {
15            Some("yaml") | Some("yml") => Format::Yaml,
16            _ => Format::Ndjson,
17        }
18    }
19}
20
21enum Line {
22    Rule,
23    List,
24    Item,
25    Trailer,
26}
27
28fn classify(v: &Value) -> Option<Line> {
29    if v.get("rule_id").is_some() {
30        return Some(Line::Rule);
31    }
32    if v.get("item_id").is_some() {
33        return Some(Line::Item);
34    }
35    if v.get("list_id").is_some() {
36        return Some(Line::List);
37    }
38    if v.get("exported_count").is_some() || v.get("exported_exception_list_count").is_some() {
39        return Some(Line::Trailer);
40    }
41    None
42}
43
44#[derive(Debug, Clone, PartialEq, Default)]
45pub struct Bundle {
46    pub rules: Vec<Rule>,
47    pub lists: Vec<ExceptionList>,
48    pub items: Vec<ExceptionItem>,
49    pub summary: Option<ExportSummary>,
50}
51
52pub fn decode_bundle(body: &str) -> Result<Bundle> {
53    let mut out = Bundle::default();
54    for (i, line) in body.lines().enumerate() {
55        let line = line.trim();
56        if line.is_empty() {
57            continue;
58        }
59        let value: Value = serde_json::from_str(line).map_err(|e| {
60            Error::new(
61                ErrorKind::Error,
62                format!("invalid JSON on line {}: {e}", i + 1),
63            )
64        })?;
65        let at = |e: Error| Error::new(ErrorKind::Error, format!("line {}: {}", i + 1, e.message));
66        match classify(&value) {
67            Some(Line::Rule) => out.rules.push(Rule::from_value(value).map_err(at)?),
68            Some(Line::List) => out
69                .lists
70                .push(ExceptionList::from_value(value).map_err(at)?),
71            Some(Line::Item) => out
72                .items
73                .push(ExceptionItem::from_value(value).map_err(at)?),
74            Some(Line::Trailer) => {
75                out.summary =
76                    Some(serde_json::from_value(value).map_err(|e| {
77                        Error::new(ErrorKind::Error, format!("line {}: {e}", i + 1))
78                    })?);
79            }
80            None => {
81                return Err(Error::new(
82                    ErrorKind::Error,
83                    format!(
84                        "line {}: not a rule (no rule_id), exception list (no list_id), exception item (no item_id), or export trailer",
85                        i + 1
86                    ),
87                ));
88            }
89        }
90    }
91    Ok(out)
92}
93
94pub fn encode_bundle(bundle: &Bundle) -> Result<String> {
95    let mut out = String::new();
96    for r in &bundle.rules {
97        out.push_str(
98            &serde_json::to_string(r)
99                .map_err(|e| Error::new(ErrorKind::Error, format!("encoding rule: {e}")))?,
100        );
101        out.push('\n');
102    }
103    for l in &bundle.lists {
104        out.push_str(
105            &serde_json::to_string(l).map_err(|e| {
106                Error::new(ErrorKind::Error, format!("encoding exception list: {e}"))
107            })?,
108        );
109        out.push('\n');
110    }
111    for i in &bundle.items {
112        out.push_str(
113            &serde_json::to_string(i).map_err(|e| {
114                Error::new(ErrorKind::Error, format!("encoding exception item: {e}"))
115            })?,
116        );
117        out.push('\n');
118    }
119    Ok(out)
120}
121
122pub fn encode_ndjson(rules: &[Rule]) -> Result<String> {
123    let mut out = String::new();
124    for r in rules {
125        let line = serde_json::to_string(r)
126            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding rule: {e}")))?;
127        out.push_str(&line);
128        out.push('\n');
129    }
130    Ok(out)
131}
132
133pub fn decode_ndjson(body: &str) -> Result<(Vec<Rule>, Option<ExportSummary>)> {
134    let b = decode_bundle(body)?;
135    Ok((b.rules, b.summary))
136}
137
138pub fn encode_yaml(rules: &[Rule]) -> Result<String> {
139    serde_yaml_ng::to_string(rules)
140        .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}")))
141}
142
143pub fn decode_yaml(body: &str) -> Result<Vec<Rule>> {
144    let values: Vec<Value> = serde_yaml_ng::from_str(body)
145        .map_err(|e| Error::new(ErrorKind::Error, format!("parsing YAML: {e}")))?;
146
147    values
148        .into_iter()
149        .enumerate()
150        .map(|(i, v)| {
151            Rule::from_value(v).map_err(|e| {
152                Error::new(
153                    ErrorKind::Error,
154                    format!("rule at index {i}: {}", e.message),
155                )
156            })
157        })
158        .collect()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use serde_json::json;
165    use std::path::Path;
166
167    fn rule(id: &str) -> Rule {
168        Rule::from_value(json!({
169            "rule_id": id, "name": format!("rule {id}"), "type": "query",
170            "query": "event.category:process", "severity": "low", "risk_score": 21
171        }))
172        .unwrap()
173    }
174
175    const REAL_EXPORT: &str = concat!(
176        r#"{"rule_id":"a","name":"rule a","type":"query"}"#,
177        "\n",
178        r#"{"exported_count":1,"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0}"#,
179        "\n"
180    );
181
182    #[test]
183    fn decode_ndjson_separates_the_trailer_from_the_rules() {
184        let (rules, summary) = decode_ndjson(REAL_EXPORT).unwrap();
185        assert_eq!(rules.len(), 1, "the trailer must not be parsed as a rule");
186        assert_eq!(rules[0].rule_id().unwrap(), "a");
187        assert_eq!(summary.unwrap().exported_count, 1);
188    }
189
190    #[test]
191    fn decode_ndjson_handles_a_body_that_is_only_a_trailer() {
192        let body = r#"{"exported_count":0,"exported_rules_count":0,"missing_rules_count":0}"#;
193        let (rules, summary) = decode_ndjson(body).unwrap();
194        assert!(rules.is_empty());
195        assert_eq!(summary.unwrap().exported_count, 0);
196    }
197
198    #[test]
199    fn decode_ndjson_tolerates_blank_lines() {
200        let body = format!("\n{}\n\n", REAL_EXPORT.trim());
201        assert_eq!(decode_ndjson(&body).unwrap().0.len(), 1);
202    }
203
204    #[test]
205    fn decode_ndjson_reports_the_line_number_of_bad_json() {
206        let body = "{\"rule_id\":\"a\"}\nnot json\n";
207        let err = decode_ndjson(body).unwrap_err();
208        assert!(
209            err.message.contains("line 2"),
210            "message must locate the fault: {}",
211            err.message
212        );
213    }
214
215    #[test]
216    fn decode_ndjson_names_the_line_of_a_rule_it_rejects() {
217        let body = "{\"rule_id\":\"a\"}\n{\"name\":\"no id\"}\n";
218        let err = decode_ndjson(body).unwrap_err();
219        assert!(
220            err.message.contains("line 2"),
221            "message must locate the fault: {}",
222            err.message
223        );
224        assert!(err.message.contains("rule_id"), "{}", err.message);
225    }
226
227    #[test]
228    fn decode_ndjson_rejects_a_non_string_rule_id() {
229        let err = decode_ndjson("{\"rule_id\":7}\n").unwrap_err();
230        assert!(err.message.contains("line 1"), "{}", err.message);
231        assert!(err.message.contains("string"), "{}", err.message);
232    }
233
234    #[test]
235    fn ndjson_round_trips() {
236        let rules = vec![rule("a"), rule("b")];
237        let encoded = encode_ndjson(&rules).unwrap();
238        assert_eq!(
239            encoded.lines().count(),
240            2,
241            "one rule per line, no trailer on write"
242        );
243        assert_eq!(decode_ndjson(&encoded).unwrap().0, rules);
244    }
245
246    #[test]
247    fn yaml_round_trips() {
248        let rules = vec![rule("a"), rule("b")];
249        let encoded = encode_yaml(&rules).unwrap();
250        assert_eq!(decode_yaml(&encoded).unwrap(), rules);
251    }
252
253    #[test]
254    fn the_two_formats_carry_identical_data() {
255        let rules = vec![rule("a")];
256        let via_ndjson = decode_ndjson(&encode_ndjson(&rules).unwrap()).unwrap().0;
257        let via_yaml = decode_yaml(&encode_yaml(&rules).unwrap()).unwrap();
258        assert_eq!(
259            via_ndjson, via_yaml,
260            "YAML and NDJSON are two skins on one model"
261        );
262    }
263
264    #[test]
265    fn format_is_chosen_by_file_extension() {
266        assert_eq!(Format::from_path(Path::new("rules.yaml")), Format::Yaml);
267        assert_eq!(Format::from_path(Path::new("rules.yml")), Format::Yaml);
268        assert_eq!(Format::from_path(Path::new("rules.ndjson")), Format::Ndjson);
269        assert_eq!(Format::from_path(Path::new("rules.json")), Format::Ndjson);
270        assert_eq!(Format::from_path(Path::new("noextension")), Format::Ndjson);
271    }
272
273    #[test]
274    fn decode_yaml_rejects_an_entry_without_rule_id() {
275        let yaml = "- {name: test}\n";
276        let err = decode_yaml(yaml).unwrap_err();
277        assert!(
278            err.message.contains("index 0"),
279            "error must name the index: {}",
280            err.message
281        );
282        assert!(
283            err.message.contains("rule_id"),
284            "error must mention rule_id: {}",
285            err.message
286        );
287    }
288
289    #[test]
290    fn decode_yaml_reports_the_index_of_a_bad_entry() {
291        let yaml = "- {rule_id: a, name: test}\n- {name: test}\n";
292        let err = decode_yaml(yaml).unwrap_err();
293        assert!(
294            err.message.contains("index 1"),
295            "error must name the index: {}",
296            err.message
297        );
298    }
299
300    const BUNDLE: &str = concat!(
301        r#"{"rule_id":"r","name":"R","type":"query","exceptions_list":[{"id":"L","list_id":"l","type":"detection","namespace_type":"single"}]}"#,
302        "\n",
303        r#"{"id":"L","list_id":"l","type":"detection","name":"L","namespace_type":"single","tie_breaker_id":"t"}"#,
304        "\n",
305        r#"{"id":"I","item_id":"i","list_id":"l","type":"simple","name":"I","namespace_type":"single","entries":[]}"#,
306        "\n",
307        r#"{"exported_count":2,"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0,"exported_exception_list_count":1,"exported_exception_list_item_count":1,"missing_exception_lists":[],"missing_exception_list_items":[]}"#,
308        "\n"
309    );
310
311    #[test]
312    fn decode_bundle_separates_all_four_line_kinds() {
313        let b = decode_bundle(BUNDLE).unwrap();
314        assert_eq!(b.rules.len(), 1);
315        assert_eq!(b.lists.len(), 1);
316        assert_eq!(b.items.len(), 1);
317        assert_eq!(b.summary.as_ref().unwrap().exported_exception_list_count, 1);
318    }
319
320    #[test]
321    fn a_bundle_no_longer_fails_as_a_rule_list() {
322        assert!(
323            decode_bundle(BUNDLE).is_ok(),
324            "measured fact 2: this is the shipped failure"
325        );
326    }
327
328    #[test]
329    fn an_item_is_not_classified_as_a_list() {
330        let line = r#"{"item_id":"i","list_id":"l","name":"I"}"#;
331        let b = decode_bundle(line).unwrap();
332        assert_eq!(b.items.len(), 1, "item_id must be tested before list_id");
333        assert!(b.lists.is_empty());
334    }
335
336    #[test]
337    fn the_exception_export_trailer_is_recognised() {
338        let line = r#"{"exported_exception_list_count":1,"exported_exception_list_item_count":2,"missing_exception_lists":[],"missing_exception_list_items":[],"missing_exception_lists_count":0,"missing_exception_list_item_count":0}"#;
339        let b = decode_bundle(line).unwrap();
340        assert!(b.rules.is_empty() && b.lists.is_empty() && b.items.is_empty());
341        assert_eq!(b.summary.unwrap().exported_exception_list_count, 1);
342    }
343
344    #[test]
345    fn an_unclassifiable_line_is_refused_by_line_number() {
346        let body = "{\"rule_id\":\"a\"}\n{\"mystery\":true}\n";
347        let err = decode_bundle(body).unwrap_err();
348        assert!(err.message.contains("line 2"), "{}", err.message);
349        assert!(err.message.contains("no rule_id"), "{}", err.message);
350        assert!(err.message.contains("no list_id"), "{}", err.message);
351        assert!(err.message.contains("no item_id"), "{}", err.message);
352    }
353
354    #[test]
355    fn a_recognized_malformed_trailer_is_not_discarded() {
356        let error = decode_bundle("{\"exported_count\":\"one\"}\n").unwrap_err();
357        assert!(error.message.contains("line 1"), "{}", error.message);
358    }
359
360    #[test]
361    fn decode_ndjson_still_returns_only_rules_and_the_trailer() {
362        let (rules, summary) = decode_ndjson(BUNDLE).unwrap();
363        assert_eq!(rules.len(), 1, "the wrapper keeps its old contract");
364        assert_eq!(summary.unwrap().exported_count, 2);
365    }
366
367    #[test]
368    fn encode_bundle_writes_rules_then_lists_then_items_and_no_trailer() {
369        let b = decode_bundle(BUNDLE).unwrap();
370        let out = encode_bundle(&b).unwrap();
371        let lines: Vec<&str> = out.lines().collect();
372        assert_eq!(lines.len(), 3, "the trailer is never written back");
373        assert!(lines[0].contains("\"rule_id\""));
374        assert!(lines[1].contains("\"list_id\"") && !lines[1].contains("\"item_id\""));
375        assert!(lines[2].contains("\"item_id\""));
376
377        let back = decode_bundle(&out).unwrap();
378        assert_eq!(back.rules, b.rules, "rules round-trip");
379        assert_eq!(back.lists, b.lists, "lists round-trip");
380        assert_eq!(back.items, b.items, "items round-trip");
381    }
382}