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 = read_regular_file(&path)?;
29            let (mut rules, lists, items) = decode_rule_file(&body, Format::from_path(&path))?;
30            mirror.rules.append(&mut rules);
31            for mut list in lists {
32                let list_items = split_items(&mut list)?;
33                mirror.lists.push(list);
34                mirror.items.extend(list_items);
35            }
36            for item in &items {
37                validate_top_level_item(item)?;
38            }
39            mirror.items.extend(items);
40        }
41    }
42
43    let lists_path = super::exceptions_dir(dir);
44    if let Some(paths) = mirror_root_files(&lists_path)? {
45        for path in paths {
46            let body = read_regular_file(&path)?;
47            let mut list = decode_list_file(&body, Format::from_path(&path))?;
48            let items = split_items(&mut list)?;
49            mirror.lists.push(list);
50            mirror.items.extend(items);
51        }
52    }
53
54    normalize::sort_rules(&mut mirror.rules);
55    normalize::sort_lists(&mut mirror.lists);
56    normalize::sort_items(&mut mirror.items);
57    Ok(mirror)
58}
59
60fn validate_top_level_item(item: &ExceptionItem) -> Result<()> {
61    let item_id = item.item_id()?;
62    if item_id.is_empty() {
63        return Err(Error::new(
64            ErrorKind::Error,
65            "exception item field item_id must be a non-empty string",
66        ));
67    }
68    let list_id = item.list_id()?;
69    if list_id.is_empty() {
70        return Err(Error::new(
71            ErrorKind::Error,
72            "exception item field list_id must be a non-empty string",
73        ));
74    }
75    match item.as_map().get("namespace_type") {
76        None => Ok(()),
77        Some(Value::String(value)) if !value.is_empty() => Ok(()),
78        Some(_) => Err(Error::new(
79            ErrorKind::Error,
80            "exception item field namespace_type must be a non-empty string",
81        )),
82    }
83}
84
85/// `read_mirror`'s rules, for the rules-only callers.
86pub fn read_local(dir: &Path) -> Result<Vec<Rule>> {
87    Ok(read_mirror(dir)?.rules)
88}
89
90/// The files in a mirror root directory, or `None` when the root is absent.
91///
92/// A root that exists but is a symlink or a non-directory fails closed, so an
93/// escaped mirror cannot start a destructive plan. The root check is a static
94/// `symlink_metadata`; `read_dir` cannot carry `O_NOFOLLOW`, so a concurrent
95/// swap of the root itself for a symlink is not guarded atomically. That swap
96/// already requires write access to the mirror directory, whose owner could
97/// edit the rule files directly, so the tighter boundary is the file read in
98/// `read_regular_file`.
99fn mirror_root_files(dir: &Path) -> Result<Option<Vec<PathBuf>>> {
100    match std::fs::symlink_metadata(dir) {
101        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
102        Err(e) => Err(Error::new(
103            ErrorKind::Error,
104            format!("reading {}: {e}", dir.display()),
105        )),
106        Ok(metadata) => {
107            if metadata.file_type().is_symlink() || !metadata.is_dir() {
108                return Err(Error::new(
109                    ErrorKind::Error,
110                    format!("mirror directory {} is not a real directory", dir.display()),
111                ));
112            }
113            Ok(Some(mirror_files(dir)?))
114        }
115    }
116}
117
118/// The files in a mirror directory that look like rule or list files.
119fn mirror_files(dir: &Path) -> Result<Vec<PathBuf>> {
120    let mut out = Vec::new();
121    for entry in std::fs::read_dir(dir)
122        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?
123    {
124        if let Some(path) = mirror_entry_path(dir, entry)? {
125            out.push(path);
126        }
127    }
128    out.sort();
129    Ok(out)
130}
131
132/// Classify one directory entry into a path to read, `None` to ignore, or an
133/// error. A read error, symlink, or directory on a recognized extension fails
134/// closed; unrecognized entries are ignored without opening them.
135fn mirror_entry_path(
136    dir: &Path,
137    entry: std::io::Result<std::fs::DirEntry>,
138) -> Result<Option<PathBuf>> {
139    let entry = entry
140        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", dir.display())))?;
141    let path = entry.path();
142    if !super::is_rule_file(&path) {
143        return Ok(None);
144    }
145    // A recognized extension must be a regular file, never a symlink (which
146    // could escape the mirror) or a directory.
147    let file_type = entry
148        .file_type()
149        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
150    if !file_type.is_file() {
151        return Err(Error::new(
152            ErrorKind::Error,
153            format!("mirror entry {} is not a regular file", path.display()),
154        ));
155    }
156    Ok(Some(path))
157}
158
159/// Read a mirror file, refusing to follow a symlink.
160///
161/// On unix, `O_NOFOLLOW` makes the refusal atomic: a file swapped for a symlink
162/// between enumeration and read is rejected at the open, not after the read has
163/// already followed it. Elsewhere the refusal is a non-atomic
164/// `symlink_metadata` check, the same strength as the static check. This is the
165/// content boundary, so it must be as tight as the static
166/// `DirEntry::file_type()` check in `mirror_entry_path`.
167#[cfg(unix)]
168fn read_regular_file(path: &Path) -> Result<String> {
169    use std::io::Read;
170    use std::os::unix::fs::OpenOptionsExt;
171    let mut file = std::fs::OpenOptions::new()
172        .read(true)
173        .custom_flags(libc::O_NOFOLLOW)
174        .open(path)
175        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
176    let mut body = String::new();
177    file.read_to_string(&mut body)
178        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
179    Ok(body)
180}
181
182#[cfg(not(unix))]
183fn read_regular_file(path: &Path) -> Result<String> {
184    let metadata = std::fs::symlink_metadata(path)
185        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
186    if metadata.file_type().is_symlink() {
187        return Err(Error::new(
188            ErrorKind::Error,
189            format!("mirror entry {} is not a regular file", path.display()),
190        ));
191    }
192    std::fs::read_to_string(path)
193        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))
194}
195
196/// Decode a rule file: one or more rules, optionally followed by the
197/// `rule_default` containers that belong to them, and any items a dropped
198/// export bundle carried.
199///
200/// NDJSON routes through `codec::decode_bundle`, so a `rules export` file
201/// (with its trailer and item lines) dropped into `rules/` decodes instead of
202/// failing, and items are never misfiled as containers.
203fn decode_rule_file(
204    body: &str,
205    format: Format,
206) -> Result<(Vec<Rule>, Vec<ExceptionList>, Vec<ExceptionItem>)> {
207    match format {
208        Format::Ndjson => {
209            let bundle = crate::codec::decode_bundle(body)?;
210            Ok((bundle.rules, bundle.lists, bundle.items))
211        }
212        Format::Yaml => {
213            let values: Vec<Value> = serde_yaml_ng::from_str(body)
214                .map_err(|e| Error::new(ErrorKind::Error, format!("parsing YAML: {e}")))?;
215            let mut rules = Vec::new();
216            let mut lists = Vec::new();
217            let mut items = Vec::new();
218            for value in values {
219                // Order matters, matching `codec::classify`: an item carries
220                // both `item_id` and `list_id`, so `item_id` must be tested
221                // before `list_id` or the item is misfiled as a container.
222                if value.get("rule_id").is_some() {
223                    rules.push(Rule::from_value(value)?);
224                } else if value.get("item_id").is_some() {
225                    items.push(ExceptionItem::from_value(value)?);
226                } else if value.get("list_id").is_some() {
227                    lists.push(ExceptionList::from_value(value)?);
228                } else {
229                    return Err(Error::new(
230                        ErrorKind::Error,
231                        "a mirror file entry has neither rule_id, item_id, nor list_id",
232                    ));
233                }
234            }
235            Ok((rules, lists, items))
236        }
237    }
238}
239
240/// Encode a rule file: the canonical rule, then its inline `rule_default`
241/// containers. The caller passes canonical objects.
242pub(crate) fn encode_rule_file(
243    rule: &Rule,
244    inline_lists: &[ExceptionList],
245    format: Format,
246) -> Result<String> {
247    let mut objects = Vec::with_capacity(1 + inline_lists.len());
248    objects.push(rule.clone().into_value());
249    for list in inline_lists {
250        objects.push(list.clone().into_value());
251    }
252    match format {
253        Format::Yaml => serde_yaml_ng::to_string(&objects)
254            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
255        Format::Ndjson => {
256            let mut out = String::new();
257            for object in &objects {
258                out.push_str(
259                    &serde_json::to_string(object).map_err(|e| {
260                        Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}"))
261                    })?,
262                );
263                out.push('\n');
264            }
265            Ok(out)
266        }
267    }
268}
269
270/// Encode one exception-list container (with its `items` array) as its own
271/// file. The caller passes a canonical container.
272pub(crate) fn encode_list_file(list: &ExceptionList, format: Format) -> Result<String> {
273    match format {
274        Format::Yaml => serde_yaml_ng::to_string(list.as_map())
275            .map_err(|e| Error::new(ErrorKind::Error, format!("encoding YAML: {e}"))),
276        Format::Ndjson => Ok(format!(
277            "{}\n",
278            serde_json::to_string(list.as_map())
279                .map_err(|e| Error::new(ErrorKind::Error, format!("encoding NDJSON: {e}")))?
280        )),
281    }
282}
283
284fn decode_list_file(body: &str, format: Format) -> Result<ExceptionList> {
285    let value = match format {
286        Format::Yaml => serde_yaml_ng::from_str(body)
287            .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?,
288        Format::Ndjson => {
289            let mut lines = body.lines().filter(|line| !line.trim().is_empty());
290            let line = lines
291                .next()
292                .ok_or_else(|| Error::new(ErrorKind::Error, "empty exception list file"))?;
293            if lines.next().is_some() {
294                return Err(Error::new(
295                    ErrorKind::Error,
296                    "an exception mirror file must contain exactly one nonblank NDJSON object",
297                ));
298            }
299            serde_json::from_str(line.trim())
300                .map_err(|e| Error::new(ErrorKind::Error, format!("parsing exception list: {e}")))?
301        }
302    };
303    ExceptionList::from_value(value)
304}
305
306/// Split a container's `items` array into items, removing it from the container
307/// so container drift compares containers, not their items.
308fn split_items(list: &mut ExceptionList) -> Result<Vec<ExceptionItem>> {
309    let items = match list.as_map_mut().remove("items") {
310        None => return Ok(Vec::new()),
311        Some(Value::Array(items)) => items,
312        Some(_) => {
313            return Err(Error::new(
314                ErrorKind::Error,
315                "exception list field items must be an array",
316            ));
317        }
318    };
319    let list_id = list.list_id()?.to_string();
320    let namespace = list.namespace_type().to_string();
321    items
322        .into_iter()
323        .map(|value| {
324            let mut item = ExceptionItem::from_value(value)?;
325            // Key an item by its container, not its own body. An item that
326            // omits `namespace_type` (which defaults to "single") inside an
327            // `agnostic` container would otherwise group under the wrong key
328            // and reconcile as a deletion (spec 5.4).
329            item.as_map_mut()
330                .insert("list_id".into(), Value::String(list_id.clone()));
331            item.as_map_mut()
332                .insert("namespace_type".into(), Value::String(namespace.clone()));
333            Ok(item)
334        })
335        .collect()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    // The `Ok(None)` (unrecognized extension) and symlink/directory-rejection
343    // arms are covered by the `read_local_skips_non_rule_files...` test in
344    // `state/mod.rs` and the `#[cfg(unix)]` symlink tests in `tests/state.rs`;
345    // only the injected-error arm is reachable from a bare `io::Result`.
346    #[test]
347    fn mirror_entry_path_reports_an_entry_read_error_naming_the_directory() {
348        let dir = Path::new("/mirror");
349        let err = mirror_entry_path(
350            dir,
351            Err(std::io::Error::new(
352                std::io::ErrorKind::PermissionDenied,
353                "denied",
354            )),
355        )
356        .unwrap_err();
357        assert_eq!(err.kind, ErrorKind::Error);
358        assert!(err.message.contains("/mirror"), "{}", err.message);
359    }
360}