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}