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::{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/// An export trailer has no `rule_id` and has an export counter.
28fn is_summary(v: &Value) -> bool {
29    v.get("rule_id").is_none() && v.get("exported_count").is_some()
30}
31
32pub fn encode_ndjson(rules: &[Rule]) -> Result<String> {
33    let mut out = String::new();
34    for r in rules {
35        let line = serde_json::to_string(r)
36            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding rule: {e}")))?;
37        out.push_str(&line);
38        out.push('\n');
39    }
40    Ok(out)
41}
42
43pub fn decode_ndjson(body: &str) -> Result<(Vec<Rule>, Option<ExportSummary>)> {
44    let mut rules = Vec::new();
45    let mut summary = None;
46
47    for (i, line) in body.lines().enumerate() {
48        let line = line.trim();
49        if line.is_empty() {
50            continue;
51        }
52        let value: Value = serde_json::from_str(line).map_err(|e| {
53            Error::new(
54                ErrorKind::Error,
55                format!("invalid JSON on line {}: {e}", i + 1),
56            )
57        })?;
58
59        if is_summary(&value) {
60            summary = serde_json::from_value(value).ok();
61            continue;
62        }
63        // Include the line number so large exports identify the rejected rule.
64        rules.push(
65            Rule::from_value(value).map_err(|e| {
66                Error::new(ErrorKind::Error, format!("line {}: {}", i + 1, e.message))
67            })?,
68        );
69    }
70
71    Ok((rules, summary))
72}
73
74pub fn encode_yaml(rules: &[Rule]) -> Result<String> {
75    serde_yaml_ng::to_string(rules)
76        .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}")))
77}
78
79pub fn decode_yaml(body: &str) -> Result<Vec<Rule>> {
80    let values: Vec<Value> = serde_yaml_ng::from_str(body)
81        .map_err(|e| Error::new(ErrorKind::Error, format!("parsing YAML: {e}")))?;
82
83    // Apply the same `rule_id` validation as NDJSON. YAML is hand-edited, so
84    // it is more likely to omit `rule_id`.
85    values
86        .into_iter()
87        .enumerate()
88        .map(|(i, v)| {
89            Rule::from_value(v).map_err(|e| {
90                Error::new(
91                    ErrorKind::Error,
92                    format!("rule at index {i}: {}", e.message),
93                )
94            })
95        })
96        .collect()
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use serde_json::json;
103    use std::path::Path;
104
105    fn rule(id: &str) -> Rule {
106        Rule::from_value(json!({
107            "rule_id": id, "name": format!("rule {id}"), "type": "query",
108            "query": "event.category:process", "severity": "low", "risk_score": 21
109        }))
110        .unwrap()
111    }
112
113    // A one-rule export contains the rule and a 15-field summary.
114    const REAL_EXPORT: &str = concat!(
115        r#"{"rule_id":"a","name":"rule a","type":"query"}"#,
116        "\n",
117        r#"{"exported_count":1,"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0}"#,
118        "\n"
119    );
120
121    #[test]
122    fn decode_ndjson_separates_the_trailer_from_the_rules() {
123        let (rules, summary) = decode_ndjson(REAL_EXPORT).unwrap();
124        assert_eq!(rules.len(), 1, "the trailer must not be parsed as a rule");
125        assert_eq!(rules[0].rule_id().unwrap(), "a");
126        assert_eq!(summary.unwrap().exported_count, 1);
127    }
128
129    // A zero-rule export contains only the trailer.
130    #[test]
131    fn decode_ndjson_handles_a_body_that_is_only_a_trailer() {
132        let body = r#"{"exported_count":0,"exported_rules_count":0,"missing_rules_count":0}"#;
133        let (rules, summary) = decode_ndjson(body).unwrap();
134        assert!(rules.is_empty());
135        assert_eq!(summary.unwrap().exported_count, 0);
136    }
137
138    #[test]
139    fn decode_ndjson_tolerates_blank_lines() {
140        let body = format!("\n{}\n\n", REAL_EXPORT.trim());
141        assert_eq!(decode_ndjson(&body).unwrap().0.len(), 1);
142    }
143
144    #[test]
145    fn decode_ndjson_reports_the_line_number_of_bad_json() {
146        let body = "{\"rule_id\":\"a\"}\nnot json\n";
147        let err = decode_ndjson(body).unwrap_err();
148        assert!(
149            err.message.contains("line 2"),
150            "message must locate the fault: {}",
151            err.message
152        );
153    }
154
155    #[test]
156    fn decode_ndjson_names_the_line_of_a_rule_it_rejects() {
157        let body = "{\"rule_id\":\"a\"}\n{\"name\":\"no id\"}\n";
158        let err = decode_ndjson(body).unwrap_err();
159        assert!(
160            err.message.contains("line 2"),
161            "message must locate the fault: {}",
162            err.message
163        );
164        assert!(err.message.contains("rule_id"), "{}", err.message);
165    }
166
167    #[test]
168    fn decode_ndjson_rejects_a_non_string_rule_id() {
169        let err = decode_ndjson("{\"rule_id\":7}\n").unwrap_err();
170        assert!(err.message.contains("line 1"), "{}", err.message);
171        assert!(err.message.contains("string"), "{}", err.message);
172    }
173
174    #[test]
175    fn ndjson_round_trips() {
176        let rules = vec![rule("a"), rule("b")];
177        let encoded = encode_ndjson(&rules).unwrap();
178        assert_eq!(
179            encoded.lines().count(),
180            2,
181            "one rule per line, no trailer on write"
182        );
183        assert_eq!(decode_ndjson(&encoded).unwrap().0, rules);
184    }
185
186    #[test]
187    fn yaml_round_trips() {
188        let rules = vec![rule("a"), rule("b")];
189        let encoded = encode_yaml(&rules).unwrap();
190        assert_eq!(decode_yaml(&encoded).unwrap(), rules);
191    }
192
193    #[test]
194    fn the_two_formats_carry_identical_data() {
195        let rules = vec![rule("a")];
196        let via_ndjson = decode_ndjson(&encode_ndjson(&rules).unwrap()).unwrap().0;
197        let via_yaml = decode_yaml(&encode_yaml(&rules).unwrap()).unwrap();
198        assert_eq!(
199            via_ndjson, via_yaml,
200            "YAML and NDJSON are two skins on one model"
201        );
202    }
203
204    #[test]
205    fn format_is_chosen_by_file_extension() {
206        assert_eq!(Format::from_path(Path::new("rules.yaml")), Format::Yaml);
207        assert_eq!(Format::from_path(Path::new("rules.yml")), Format::Yaml);
208        assert_eq!(Format::from_path(Path::new("rules.ndjson")), Format::Ndjson);
209        assert_eq!(Format::from_path(Path::new("rules.json")), Format::Ndjson);
210        assert_eq!(Format::from_path(Path::new("noextension")), Format::Ndjson);
211    }
212
213    #[test]
214    fn decode_yaml_rejects_an_entry_without_rule_id() {
215        let yaml = "- {name: test}\n";
216        let err = decode_yaml(yaml).unwrap_err();
217        assert!(
218            err.message.contains("index 0"),
219            "error must name the index: {}",
220            err.message
221        );
222        assert!(
223            err.message.contains("rule_id"),
224            "error must mention rule_id: {}",
225            err.message
226        );
227    }
228
229    #[test]
230    fn decode_yaml_reports_the_index_of_a_bad_entry() {
231        let yaml = "- {rule_id: a, name: test}\n- {name: test}\n";
232        let err = decode_yaml(yaml).unwrap_err();
233        assert!(
234            err.message.contains("index 1"),
235            "error must name the index: {}",
236            err.message
237        );
238    }
239}