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