Skip to main content

elasticctl_api/state/
mirror.rs

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}