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