1use 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 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
27enum Line {
30 Rule,
31 List,
32 Item,
33 Trailer,
34}
35
36fn 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#[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
71pub 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 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
118pub 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 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 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 #[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 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 #[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 #[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 #[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 #[test]
408 fn encode_bundle_writes_rules_then_lists_then_items_and_no_trailer() {
409 let b = decode_bundle(BUNDLE).unwrap(); 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}