elasticctl_api/state/
pull.rs1use 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
16pub 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 let lock = acquire_pull(dir)?;
30 recover_pull(&lock)?;
31
32 let scope = super::scope_of(t, selectors, tag, source, &[], "pull").await?;
35 let mut remote = scope.remote(t).await?;
36 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 normalize::sort_rules(&mut remote);
47
48 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 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 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 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 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 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 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}