1use super::reports::Mirror;
2use crate::codec::Format;
3use crate::model::{ExceptionItem, ExceptionList, Rule};
4use crate::normalize;
5use elasticctl_core::{Error, ErrorKind, Result};
6use serde_json::Value;
7use std::path::{Path, PathBuf};
8
9pub fn read_mirror(dir: &Path) -> Result<Mirror> {
10 let mut mirror = Mirror {
11 rules: Vec::new(),
12 lists: Vec::new(),
13 items: Vec::new(),
14 value_lists: Vec::new(),
15 has_value_lists: false,
16 };
17
18 let rules_path = super::rules_dir(dir);
19 if let Some(paths) = mirror_root_files(&rules_path)? {
20 for path in paths {
21 let body = read_regular_file(&path)?;
22 let (mut rules, lists, items) = decode_rule_file(&body, Format::from_path(&path))?;
23 mirror.rules.append(&mut rules);
24 for mut list in lists {
25 let list_items = split_items(&mut list)?;
26 mirror.lists.push(list);
27 mirror.items.extend(list_items);
28 }
29 for item in &items {
30 validate_top_level_item(item)?;
31 }
32 mirror.items.extend(items);
33 }
34 }
35
36 let lists_path = super::exceptions_dir(dir);
37 if let Some(paths) = mirror_root_files(&lists_path)? {
38 for path in paths {
39 let body = read_regular_file(&path)?;
40 let mut list = decode_list_file(&body, Format::from_path(&path))?;
41 let items = split_items(&mut list)?;
42 mirror.lists.push(list);
43 mirror.items.extend(items);
44 }
45 }
46
47 read_value_lists(dir, &mut mirror)?;
48 normalize::sort_rules(&mut mirror.rules);
49 normalize::sort_lists(&mut mirror.lists);
50 normalize::sort_items(&mut mirror.items);
51 Ok(mirror)
52}
53
54fn read_value_lists(dir: &Path, mirror: &mut Mirror) -> Result<()> {
55 let root = dir.join("value-lists");
56 let Some(paths) = mirror_root_files(&root)? else {
57 return Ok(());
58 };
59 mirror.has_value_lists = true;
60 let mut stems = std::collections::BTreeSet::new();
61 for path in &paths {
62 if matches!(
63 path.extension().and_then(|s| s.to_str()),
64 Some("yaml" | "txt")
65 ) {
66 let sibling = path.with_extension(if path.extension().unwrap() == "yaml" {
67 "txt"
68 } else {
69 "yaml"
70 });
71 if !paths.contains(&sibling) {
72 return Err(Error::new(
73 ErrorKind::Error,
74 format!(
75 "value-list pair {} is missing {}",
76 path.display(),
77 sibling.display()
78 ),
79 ));
80 }
81 stems.insert(path.file_stem().unwrap().to_owned());
82 }
83 }
84 for stem in stems {
85 let yaml = root.join(format!("{}.yaml", stem.to_string_lossy()));
86 let text = root.join(format!("{}.txt", stem.to_string_lossy()));
87 let stem = stem
88 .to_str()
89 .ok_or_else(|| Error::new(ErrorKind::Error, "value-list file stem must be UTF-8"))?;
90 let yaml_body = read_regular_file(&yaml)?;
91 let text_body = read_regular_file(&text)?;
92 let validation = crate::value_lists::validate_local_pair(
93 stem,
94 yaml_body.as_bytes(),
95 Some(text_body.as_bytes()),
96 );
97 match validation.pair {
98 Some(pair) => mirror.value_lists.push(pair),
99 None => {
100 return Err(validation
101 .findings
102 .refusals
103 .into_iter()
104 .next()
105 .expect("invalid pair has a refusal"));
106 }
107 }
108 }
109 let collisions = crate::value_lists::stem_collisions(
110 mirror
111 .value_lists
112 .iter()
113 .map(|p| (p.container.id.as_str(), p.stem.as_str())),
114 );
115 if !collisions.is_empty() {
116 return Err(Error::new(
117 ErrorKind::Conflict,
118 format!("value-list filename collisions: {collisions:?}"),
119 ));
120 }
121 Ok(())
122}
123
124fn validate_top_level_item(item: &ExceptionItem) -> Result<()> {
125 let item_id = item.item_id()?;
126 if item_id.is_empty() {
127 return Err(Error::new(
128 ErrorKind::Error,
129 "exception item field item_id must be a non-empty string",
130 ));
131 }
132 let list_id = item.list_id()?;
133 if list_id.is_empty() {
134 return Err(Error::new(
135 ErrorKind::Error,
136 "exception item field list_id must be a non-empty string",
137 ));
138 }
139 match item.as_map().get("namespace_type") {
140 None => Ok(()),
141 Some(Value::String(value)) if !value.is_empty() => Ok(()),
142 Some(_) => Err(Error::new(
143 ErrorKind::Error,
144 "exception item field namespace_type must be a non-empty string",
145 )),
146 }
147}
148
149pub fn read_local(dir: &Path) -> Result<Vec<Rule>> {
150 Ok(read_mirror(dir)?.rules)
151}
152
153pub(super) fn mirror_root_files(dir: &Path) -> Result<Option<Vec<PathBuf>>> {
154 match std::fs::symlink_metadata(dir) {
155 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
156 Err(e) => Err(Error::new(
157 ErrorKind::Error,
158 format!("reading {}: {e}", dir.display()),
159 )),
160 Ok(metadata) => {
161 if metadata.file_type().is_symlink() || !metadata.is_dir() {
162 return Err(Error::new(
163 ErrorKind::Error,
164 format!("mirror directory {} is not a real directory", dir.display()),
165 ));
166 }
167 Ok(Some(mirror_files(dir)?))
168 }
169 }
170}
171
172fn mirror_files(dir: &Path) -> Result<Vec<PathBuf>> {
173 let mut out = Vec::new();
174 for entry in std::fs::read_dir(dir)
175 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?
176 {
177 if let Some(path) = mirror_entry_path(dir, entry)? {
178 out.push(path);
179 }
180 }
181 out.sort();
182 Ok(out)
183}
184
185fn mirror_entry_path(
186 dir: &Path,
187 entry: std::io::Result<std::fs::DirEntry>,
188) -> Result<Option<PathBuf>> {
189 let entry = entry
190 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?;
191 let path = entry.path();
192 if !super::is_rule_file(&path)
193 && !(dir.file_name().is_some_and(|name| name == "value-lists")
194 && path.extension().is_some_and(|ext| ext == "txt"))
195 {
196 return Ok(None);
197 }
198 let file_type = entry
199 .file_type()
200 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
201 if !file_type.is_file() {
202 return Err(Error::new(
203 ErrorKind::Error,
204 format!("mirror entry {} is not a regular file", path.display()),
205 ));
206 }
207 Ok(Some(path))
208}
209
210#[cfg(unix)]
211pub(super) fn read_regular_file(path: &Path) -> Result<String> {
212 use std::io::Read;
213 use std::os::unix::fs::OpenOptionsExt;
214 let mut file = std::fs::OpenOptions::new()
215 .read(true)
216 .custom_flags(libc::O_NOFOLLOW)
217 .open(path)
218 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
219 let mut body = String::new();
220 file.read_to_string(&mut body)
221 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
222 Ok(body)
223}
224
225#[cfg(not(unix))]
226pub(super) fn read_regular_file(path: &Path) -> Result<String> {
227 let metadata = std::fs::symlink_metadata(path)
228 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
229 if metadata.file_type().is_symlink() {
230 return Err(Error::new(
231 ErrorKind::Error,
232 format!("mirror entry {} is not a regular file", path.display()),
233 ));
234 }
235 std::fs::read_to_string(path)
236 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))
237}
238
239fn decode_rule_file(
240 body: &str,
241 format: Format,
242) -> Result<(Vec<Rule>, Vec<ExceptionList>, Vec<ExceptionItem>)> {
243 match format {
244 Format::Ndjson => {
245 let bundle = crate::codec::decode_bundle(body)?;
246 Ok((bundle.rules, bundle.lists, bundle.items))
247 }
248 Format::Yaml => {
249 let values: Vec<Value> = serde_yaml_ng::from_str(body)
250 .map_err(|e| Error::new(ErrorKind::Error, format!("parsing YAML: {e}")))?;
251 let mut rules = Vec::new();
252 let mut lists = Vec::new();
253 let mut items = Vec::new();
254 for value in values {
255 if value.get("rule_id").is_some() {
256 rules.push(Rule::from_value(value)?);
257 } else if value.get("item_id").is_some() {
258 items.push(ExceptionItem::from_value(value)?);
259 } else if value.get("list_id").is_some() {
260 lists.push(ExceptionList::from_value(value)?);
261 } else {
262 return Err(Error::new(
263 ErrorKind::Error,
264 "a mirror file entry has neither rule_id, item_id, nor list_id",
265 ));
266 }
267 }
268 Ok((rules, lists, items))
269 }
270 }
271}
272
273pub(crate) fn encode_rule_file(
274 rule: &Rule,
275 inline_lists: &[ExceptionList],
276 format: Format,
277) -> Result<String> {
278 let mut objects = Vec::with_capacity(1 + inline_lists.len());
279 objects.push(rule.clone().into_value());
280 for list in inline_lists {
281 objects.push(list.clone().into_value());
282 }
283 match format {
284 Format::Yaml => serde_yaml_ng::to_string(&objects)
285 .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
286 Format::Ndjson => {
287 let mut out = String::new();
288 for object in &objects {
289 out.push_str(
290 &serde_json::to_string(object).map_err(|e| {
291 Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}"))
292 })?,
293 );
294 out.push('\n');
295 }
296 Ok(out)
297 }
298 }
299}
300
301pub(crate) fn encode_list_file(list: &ExceptionList, format: Format) -> Result<String> {
302 match format {
303 Format::Yaml => serde_yaml_ng::to_string(list.as_map())
304 .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
305 Format::Ndjson => Ok(format!(
306 "{}\n",
307 serde_json::to_string(list.as_map())
308 .map_err(|e| Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}")))?
309 )),
310 }
311}
312
313fn decode_list_file(body: &str, format: Format) -> Result<ExceptionList> {
314 let value = match format {
315 Format::Yaml => serde_yaml_ng::from_str(body)
316 .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?,
317 Format::Ndjson => {
318 let mut lines = body.lines().filter(|line| !line.trim().is_empty());
319 let line = lines
320 .next()
321 .ok_or_else(|| Error::new(ErrorKind::Error, "empty exception list file"))?;
322 if lines.next().is_some() {
323 return Err(Error::new(
324 ErrorKind::Error,
325 "an exception mirror file must contain exactly one nonblank NDJSON object",
326 ));
327 }
328 serde_json::from_str(line.trim())
329 .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?
330 }
331 };
332 ExceptionList::from_value(value)
333}
334
335fn split_items(list: &mut ExceptionList) -> Result<Vec<ExceptionItem>> {
336 let items = match list.as_map_mut().remove("items") {
337 None => return Ok(Vec::new()),
338 Some(Value::Array(items)) => items,
339 Some(_) => {
340 return Err(Error::new(
341 ErrorKind::Error,
342 "exception list field items must be an array",
343 ));
344 }
345 };
346 let list_id = list.list_id()?.to_string();
347 let namespace = list.namespace_type().to_string();
348 items
349 .into_iter()
350 .map(|value| {
351 let mut item = ExceptionItem::from_value(value)?;
352 item.as_map_mut()
353 .insert("list_id".into(), Value::String(list_id.clone()));
354 item.as_map_mut()
355 .insert("namespace_type".into(), Value::String(namespace.clone()));
356 Ok(item)
357 })
358 .collect()
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn mirror_entry_path_reports_an_entry_read_error_naming_the_directory() {
367 let dir = Path::new("/mirror");
368 let err = mirror_entry_path(
369 dir,
370 Err(std::io::Error::new(
371 std::io::ErrorKind::PermissionDenied,
372 "denied",
373 )),
374 )
375 .unwrap_err();
376 assert_eq!(err.kind, ErrorKind::Error);
377 assert!(err.message.contains("/mirror"), "{}", err.message);
378 }
379}