Skip to main content

elasticctl_api/state/
pull.rs

1//! `state pull`: write the scoped rules and the exception lists they reference.
2
3use super::reports::PullReport;
4use crate::codec::Format;
5use crate::exceptions;
6use crate::model::{ExceptionItem, ExceptionList, ListKey, Rule};
7use crate::normalize;
8use crate::rules::RuleSource;
9use crate::state::mirror::{encode_list_file, encode_rule_file};
10use crate::state::transaction::{StagedFile, acquire_pull, recover_pull, replace_staged_files};
11use elasticctl_core::{Error, ErrorKind, Result, Transport};
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16/// Pull the mirror, scoped by `source`. The `custom`/`all` default lives on the
17/// clap flag, where `--help` shows it, not in this signature (spec 5.5).
18pub async fn pull(
19    t: &Transport,
20    dir: &Path,
21    format: Format,
22    selectors: &[String],
23    tag: Option<&str>,
24    search: Option<&str>,
25    source: RuleSource,
26) -> Result<PullReport> {
27    // The sibling lock is acquired before recovery and held through the
28    // remote read and local commit. A concurrent pull therefore cannot treat
29    // this process's prepared journal as a crashed transaction.
30    let lock = acquire_pull(dir)?;
31    recover_pull(&lock)?;
32
33    // Pull reads from the stack, so selectors name stack rules. The directory
34    // may not exist yet.
35    let scope = super::scope_of(t, selectors, tag, search, source, &[], "pull").await?;
36    let mut remote = scope.remote(t).await?;
37    // An empty unselected custom/prebuilt pull is valid only when both source
38    // totals still account for the whole corpus. `customized` overlaps
39    // prebuilt, so it has no partition proof.
40    if remote.is_empty()
41        && !scope.is_scoped()
42        && matches!(scope.source, RuleSource::Custom | RuleSource::Prebuilt)
43    {
44        crate::rules::verify_source_partition(t).await?;
45    }
46    // Sort unstable server output so collision reports and writes are stable.
47    normalize::sort_rules(&mut remote);
48
49    // Spec 5.4: the mirror closes over the lists the scoped rules reference. A
50    // rule_default list belongs to one rule and is inlined in its file.
51    let wanted: BTreeSet<ListKey> = super::referenced_keys(&remote);
52    let mut fetched: BTreeMap<ListKey, (ExceptionList, Vec<ExceptionItem>)> = BTreeMap::new();
53    for key in &wanted {
54        let list = match exceptions::get_list(t, key).await {
55            Ok(list) => list,
56            // Refuse rather than silently write a mirror missing a referenced
57            // list: that truncation would only surface as a `not_found` at
58            // apply time (spec 5.2).
59            Err(e) if e.kind == ErrorKind::NotFound => {
60                return Err(Error::new(
61                    ErrorKind::NotFound,
62                    format!(
63                        "a rule references exception list \"{}\" ({}), which does not exist on \
64                         this stack",
65                        key.list_id, key.namespace_type
66                    ),
67                ));
68            }
69            Err(e) => return Err(e),
70        };
71        // The full item set is fetched, never narrowed: `state pull` takes
72        // rule-level selectors only. `state/diff.rs` item reconciliation
73        // deletes an item absent locally on the strength of this — add an
74        // item-level selector and that deletion becomes unsound (spec 5.4).
75        let items = exceptions::find_items(t, key).await?;
76        fetched.insert(key.clone(), (list, items));
77    }
78
79    let ext = match format {
80        Format::Yaml => "yaml",
81        Format::Ndjson => "ndjson",
82    };
83
84    // Canonicalize every fetched container, embed its items, then split it by
85    // whether it gets its own file or is inlined into its owning rule.
86    let mut lists_to_write: Vec<(ListKey, ExceptionList)> = Vec::new();
87    let mut inlines: Vec<(ListKey, ExceptionList)> = Vec::new();
88    let mut item_count = 0usize;
89    for (key, (list, items)) in &fetched {
90        let mut canonical = normalize::canonical_list(list);
91        let canonical_items: Vec<ExceptionItem> =
92            items.iter().map(normalize::canonical_item).collect();
93        item_count += canonical_items.len();
94        let items_value = Value::Array(
95            canonical_items
96                .iter()
97                .map(|i| i.clone().into_value())
98                .collect(),
99        );
100        canonical.as_map_mut().insert("items".into(), items_value);
101        if list.list_type() == "rule_default" {
102            inlines.push((key.clone(), canonical));
103        } else {
104            lists_to_write.push((key.clone(), canonical));
105        }
106    }
107
108    // Attach each inline list to its owning rule by the (list_id, namespace)
109    // reference the rule carries. A list whose rule is out of scope is dropped.
110    let mut inline_by_rule: BTreeMap<String, Vec<ExceptionList>> = BTreeMap::new();
111    for (key, list) in &inlines {
112        let owner = remote.iter().find_map(|rule| {
113            let matches = crate::model::exception_refs(rule)
114                .iter()
115                .any(|rf| rf.list_id == key.list_id && rf.namespace_type == key.namespace_type);
116            matches
117                .then(|| rule.rule_id().ok())
118                .flatten()
119                .map(str::to_string)
120        });
121        if let Some(rule_id) = owner {
122            inline_by_rule
123                .entry(rule_id)
124                .or_default()
125                .push(list.clone());
126        }
127    }
128
129    // Plan every filename before writing. Failing after a write would leave a
130    // partial mirror and hide later collisions.
131    let mut claimed_rules: BTreeMap<String, String> = BTreeMap::new();
132    let mut planned_rules: Vec<(String, Rule)> = Vec::with_capacity(remote.len());
133    let mut collisions: Vec<String> = Vec::new();
134
135    for rule in &remote {
136        let canonical = normalize::canonical(rule);
137        let rule_id = canonical.rule_id()?.to_string();
138        let filename = super::safe_filename(&rule_id, ext);
139
140        match claimed_rules.get(&filename) {
141            Some(other) => collisions.push(format!(
142                "\"{other}\" and \"{rule_id}\" both sanitise to \"{filename}\""
143            )),
144            None => {
145                claimed_rules.insert(filename.clone(), rule_id);
146                planned_rules.push((filename, canonical));
147            }
148        }
149    }
150
151    let mut claimed_lists: BTreeMap<String, String> = BTreeMap::new();
152    for (key, _) in &lists_to_write {
153        let qualified = format!("{} ({})", key.list_id, key.namespace_type);
154        let filename = super::safe_filename(&key.list_id, ext);
155        match claimed_lists.get(&filename) {
156            Some(other) => collisions.push(format!(
157                "\"{other}\" and \"{qualified}\" both sanitise to \"{filename}\""
158            )),
159            None => {
160                claimed_lists.insert(filename.clone(), qualified);
161            }
162        }
163    }
164
165    if !collisions.is_empty() {
166        return Err(Error::new(
167            ErrorKind::Conflict,
168            format!(
169                "{} filename collision(s); rename one id in each pair: {}",
170                collisions.len(),
171                collisions.join("; ")
172            ),
173        ));
174    }
175
176    // Encode every planned object before starting the transaction. A collision
177    // or encoding refusal must leave an existing mirror byte-for-byte intact.
178    let mut staged = Vec::with_capacity(planned_rules.len() + lists_to_write.len());
179    for (filename, canonical) in &planned_rules {
180        let rule_id = canonical.rule_id()?;
181        let inline = inline_by_rule.get(rule_id).cloned().unwrap_or_default();
182        let body = encode_rule_file(canonical, &inline, format)?;
183        staged.push(StagedFile {
184            relative: Path::new("rules").join(filename),
185            bytes: body.into_bytes(),
186        });
187    }
188
189    for (key, list) in &lists_to_write {
190        let filename = super::safe_filename(&key.list_id, ext);
191        let body = encode_list_file(list, format)?;
192        staged.push(StagedFile {
193            relative: Path::new("exceptions").join(filename),
194            bytes: body.into_bytes(),
195        });
196    }
197    replace_staged_files(&lock, &staged)?;
198
199    let target = super::rules_dir(dir);
200
201    Ok(PullReport {
202        pulled: planned_rules.len(),
203        exception_lists: fetched.len(),
204        exception_items: item_count,
205        dir: target.display().to_string(),
206        selected: scope.is_scoped().then(|| scope.selected()),
207    })
208}