Skip to main content

elasticctl_api/state/
mirror.rs

1//! Reading and writing the local mirror: rules and exception lists.
2
3use super::reports::Mirror;
4use crate::codec::Format;
5use crate::model::{ExceptionItem, ExceptionList, Rule};
6use crate::normalize;
7use elasticctl_core::{Error, ErrorKind, Result};
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10
11/// Read the local mirror under `dir`: the rules plus the exception lists they
12/// reference.
13///
14/// A rule file holds a rule followed by any `rule_default` containers that
15/// belong to it. Those containers are inlined in the rule file but are still
16/// part of the mirror: `rule_default` is an ordinary container, so it must
17/// round-trip through `diff` and `push` like any other referenced list.
18pub fn read_mirror(dir: &Path) -> Result<Mirror> {
19    let mut mirror = Mirror {
20        rules: Vec::new(),
21        lists: Vec::new(),
22        items: Vec::new(),
23    };
24
25    let rules_path = super::rules_dir(dir);
26    if let Some(paths) = mirror_root_files(&rules_path)? {
27        for path in paths {
28            let body = std::fs::read_to_string(&path).map_err(|e| {
29                Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
30            })?;
31            let (mut rules, lists, items) = decode_rule_file(&body, Format::from_path(&path))?;
32            mirror.rules.append(&mut rules);
33            for mut list in lists {
34                let list_items = split_items(&mut list)?;
35                mirror.lists.push(list);
36                mirror.items.extend(list_items);
37            }
38            for item in &items {
39                validate_top_level_item(item)?;
40            }
41            mirror.items.extend(items);
42        }
43    }
44
45    let lists_path = super::exceptions_dir(dir);
46    if let Some(paths) = mirror_root_files(&lists_path)? {
47        for path in paths {
48            let body = std::fs::read_to_string(&path).map_err(|e| {
49                Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
50            })?;
51            let mut list = decode_list_file(&body, Format::from_path(&path))?;
52            let items = split_items(&mut list)?;
53            mirror.lists.push(list);
54            mirror.items.extend(items);
55        }
56    }
57
58    normalize::sort_rules(&mut mirror.rules);
59    normalize::sort_lists(&mut mirror.lists);
60    normalize::sort_items(&mut mirror.items);
61    Ok(mirror)
62}
63
64fn validate_top_level_item(item: &ExceptionItem) -> Result<()> {
65    let item_id = item.item_id()?;
66    if item_id.is_empty() {
67        return Err(Error::new(
68            ErrorKind::Error,
69            "exception item field item_id must be a non-empty string",
70        ));
71    }
72    let list_id = item.list_id()?;
73    if list_id.is_empty() {
74        return Err(Error::new(
75            ErrorKind::Error,
76            "exception item field list_id must be a non-empty string",
77        ));
78    }
79    match item.as_map().get("namespace_type") {
80        None => Ok(()),
81        Some(Value::String(value)) if !value.is_empty() => Ok(()),
82        Some(_) => Err(Error::new(
83            ErrorKind::Error,
84            "exception item field namespace_type must be a non-empty string",
85        )),
86    }
87}
88
89/// `read_mirror`'s rules, for the rules-only callers.
90pub fn read_local(dir: &Path) -> Result<Vec<Rule>> {
91    Ok(read_mirror(dir)?.rules)
92}
93
94/// The files in a mirror root directory, or `None` when the root is absent.
95///
96/// A root that exists but is a symlink or a non-directory fails closed, so an
97/// escaped mirror cannot start a destructive plan.
98fn mirror_root_files(dir: &Path) -> Result<Option<Vec<PathBuf>>> {
99    match std::fs::symlink_metadata(dir) {
100        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
101        Err(e) => Err(Error::new(
102            ErrorKind::Error,
103            format!("reading {}: {e}", dir.display()),
104        )),
105        Ok(metadata) => {
106            if metadata.file_type().is_symlink() || !metadata.is_dir() {
107                return Err(Error::new(
108                    ErrorKind::Error,
109                    format!("mirror directory {} is not a real directory", dir.display()),
110                ));
111            }
112            Ok(Some(mirror_files(dir)?))
113        }
114    }
115}
116
117/// The files in a mirror directory that look like rule or list files.
118fn mirror_files(dir: &Path) -> Result<Vec<PathBuf>> {
119    let mut out = Vec::new();
120    for entry in std::fs::read_dir(dir)
121        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?
122    {
123        if let Some(path) = mirror_entry_path(dir, entry)? {
124            out.push(path);
125        }
126    }
127    out.sort();
128    Ok(out)
129}
130
131/// Classify one directory entry into a path to read, `None` to ignore, or an
132/// error. A read error, symlink, or directory on a recognized extension fails
133/// closed; unrecognized entries are ignored without opening them.
134fn mirror_entry_path(
135    dir: &Path,
136    entry: std::io::Result<std::fs::DirEntry>,
137) -> Result<Option<PathBuf>> {
138    let entry = entry
139        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?;
140    let path = entry.path();
141    if !super::is_rule_file(&path) {
142        return Ok(None);
143    }
144    // A recognized extension must be a regular file, never a symlink (which
145    // could escape the mirror) or a directory.
146    let file_type = entry
147        .file_type()
148        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
149    if !file_type.is_file() {
150        return Err(Error::new(
151            ErrorKind::Error,
152            format!("mirror entry {} is not a regular file", path.display()),
153        ));
154    }
155    Ok(Some(path))
156}
157
158/// Decode a rule file: one or more rules, optionally followed by the
159/// `rule_default` containers that belong to them, and any items a dropped
160/// export bundle carried.
161///
162/// NDJSON routes through `codec::decode_bundle`, so a `rules export` file
163/// (with its trailer and item lines) dropped into `rules/` decodes instead of
164/// failing, and items are never misfiled as containers.
165fn decode_rule_file(
166    body: &str,
167    format: Format,
168) -> Result<(Vec<Rule>, Vec<ExceptionList>, Vec<ExceptionItem>)> {
169    match format {
170        Format::Ndjson => {
171            let bundle = crate::codec::decode_bundle(body)?;
172            Ok((bundle.rules, bundle.lists, bundle.items))
173        }
174        Format::Yaml => {
175            let values: Vec<Value> = serde_yaml_ng::from_str(body)
176                .map_err(|e| Error::new(ErrorKind::Error, format!("parsing YAML: {e}")))?;
177            let mut rules = Vec::new();
178            let mut lists = Vec::new();
179            let mut items = Vec::new();
180            for value in values {
181                // Order matters, matching `codec::classify`: an item carries
182                // both `item_id` and `list_id`, so `item_id` must be tested
183                // before `list_id` or the item is misfiled as a container.
184                if value.get("rule_id").is_some() {
185                    rules.push(Rule::from_value(value)?);
186                } else if value.get("item_id").is_some() {
187                    items.push(ExceptionItem::from_value(value)?);
188                } else if value.get("list_id").is_some() {
189                    lists.push(ExceptionList::from_value(value)?);
190                } else {
191                    return Err(Error::new(
192                        ErrorKind::Error,
193                        "a mirror file entry has neither rule_id, item_id, nor list_id",
194                    ));
195                }
196            }
197            Ok((rules, lists, items))
198        }
199    }
200}
201
202/// Encode a rule file: the canonical rule, then its inline `rule_default`
203/// containers. The caller passes canonical objects.
204pub(crate) fn encode_rule_file(
205    rule: &Rule,
206    inline_lists: &[ExceptionList],
207    format: Format,
208) -> Result<String> {
209    let mut objects = Vec::with_capacity(1 + inline_lists.len());
210    objects.push(rule.clone().into_value());
211    for list in inline_lists {
212        objects.push(list.clone().into_value());
213    }
214    match format {
215        Format::Yaml => serde_yaml_ng::to_string(&objects)
216            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
217        Format::Ndjson => {
218            let mut out = String::new();
219            for object in &objects {
220                out.push_str(
221                    &serde_json::to_string(object).map_err(|e| {
222                        Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}"))
223                    })?,
224                );
225                out.push('\n');
226            }
227            Ok(out)
228        }
229    }
230}
231
232/// Encode one exception-list container (with its `items` array) as its own
233/// file. The caller passes a canonical container.
234pub(crate) fn encode_list_file(list: &ExceptionList, format: Format) -> Result<String> {
235    match format {
236        Format::Yaml => serde_yaml_ng::to_string(list.as_map())
237            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
238        Format::Ndjson => Ok(format!(
239            "{}\n",
240            serde_json::to_string(list.as_map())
241                .map_err(|e| Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}")))?
242        )),
243    }
244}
245
246fn decode_list_file(body: &str, format: Format) -> Result<ExceptionList> {
247    let value = match format {
248        Format::Yaml => serde_yaml_ng::from_str(body)
249            .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?,
250        Format::Ndjson => {
251            let mut lines = body.lines().filter(|line| !line.trim().is_empty());
252            let line = lines
253                .next()
254                .ok_or_else(|| Error::new(ErrorKind::Error, "empty exception list file"))?;
255            if lines.next().is_some() {
256                return Err(Error::new(
257                    ErrorKind::Error,
258                    "an exception mirror file must contain exactly one nonblank NDJSON object",
259                ));
260            }
261            serde_json::from_str(line.trim())
262                .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?
263        }
264    };
265    ExceptionList::from_value(value)
266}
267
268/// Split a container's `items` array into items, removing it from the container
269/// so container drift compares containers, not their items.
270fn split_items(list: &mut ExceptionList) -> Result<Vec<ExceptionItem>> {
271    let items = match list.as_map_mut().remove("items") {
272        None => return Ok(Vec::new()),
273        Some(Value::Array(items)) => items,
274        Some(_) => {
275            return Err(Error::new(
276                ErrorKind::Error,
277                "exception list field items must be an array",
278            ));
279        }
280    };
281    let list_id = list.list_id()?.to_string();
282    let namespace = list.namespace_type().to_string();
283    items
284        .into_iter()
285        .map(|value| {
286            let mut item = ExceptionItem::from_value(value)?;
287            // Key an item by its container, not its own body. An item that
288            // omits `namespace_type` (which defaults to "single") inside an
289            // `agnostic` container would otherwise group under the wrong key
290            // and reconcile as a deletion (spec 5.4).
291            item.as_map_mut()
292                .insert("list_id".into(), Value::String(list_id.clone()));
293            item.as_map_mut()
294                .insert("namespace_type".into(), Value::String(namespace.clone()));
295            Ok(item)
296        })
297        .collect()
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn mirror_entry_path_reports_a_directory_read_error() {
306        let dir = Path::new("/mirror");
307        let err = mirror_entry_path(
308            dir,
309            Err(std::io::Error::new(
310                std::io::ErrorKind::PermissionDenied,
311                "denied",
312            )),
313        )
314        .unwrap_err();
315        assert_eq!(err.kind, ErrorKind::Error);
316        assert!(err.message.contains("/mirror"), "{}", err.message);
317    }
318}