1use 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 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
27fn 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 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 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 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 #[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}