Skip to main content

elasticctl_api/
codec.rs

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