1mod list_endpoints;
2mod value_lists;
3pub use list_endpoints::{
4 ListFilter, count_items, create_item, create_list, delete_item, delete_list, export_lists,
5 find_items, find_lists, get_list, import_lists, resolve_ids, update_item, update_list,
6};
7pub(crate) use value_lists::value_lists_bootstrapped;
8
9use crate::codec::{self, Bundle, Format};
10use crate::model::{ExceptionItem, ExceptionList, ListKey};
11use crate::normalize;
12use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
13use elasticctl_core::{Error, ErrorKind, Result, Transport};
14use serde::Serialize;
15use serde_json::{Value, json};
16use std::collections::BTreeSet;
17use std::path::Path;
18
19const NAMESPACES: [&str; 2] = ["single", "agnostic"];
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct ListReport {
23 pub total: usize,
24 pub lists: Vec<ExceptionList>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct ListDetail {
29 pub list: ExceptionList,
30 pub items: Vec<ExceptionItem>,
31}
32
33fn namespaces_to_search(namespace: Option<&str>) -> Vec<&str> {
34 match namespace {
35 Some(ns) => vec![ns],
36 None => NAMESPACES.to_vec(),
37 }
38}
39
40async fn resolve_list_key(
41 t: &Transport,
42 list_id: &str,
43 namespace: Option<&str>,
44) -> Result<ListKey> {
45 if let Some(ns) = namespace {
46 let key = ListKey {
47 list_id: list_id.to_string(),
48 namespace_type: ns.to_string(),
49 };
50 match get_list(t, &key).await {
51 Ok(_) => return Ok(key),
52 Err(e) if e.kind == ErrorKind::NotFound => {
53 return Err(Error::new(
54 ErrorKind::NotFound,
55 format!("exception list not found: {list_id} ({ns})"),
56 ));
57 }
58 Err(e) => return Err(e),
59 }
60 }
61
62 let mut matches = Vec::new();
63 for ns in NAMESPACES {
64 let key = ListKey {
65 list_id: list_id.to_string(),
66 namespace_type: ns.to_string(),
67 };
68 match get_list(t, &key).await {
69 Ok(_) => matches.push(key),
70 Err(e) if e.kind == ErrorKind::NotFound => {}
71 Err(e) => return Err(e),
72 }
73 }
74 match matches.len() {
75 1 => Ok(matches.pop().expect("one match")),
76 0 => Err(Error::new(
77 ErrorKind::NotFound,
78 format!("exception list not found: {list_id}"),
79 )),
80 _ => Err(Error::new(
81 ErrorKind::Conflict,
82 format!(
83 "exception list '{list_id}' exists in both the 'single' and 'agnostic' \
84 namespaces; pass --namespace to select one"
85 ),
86 )),
87 }
88}
89
90async fn all_list_keys(t: &Transport, namespace: Option<&str>) -> Result<Vec<ListKey>> {
91 let mut keys = Vec::new();
92 for ns in namespaces_to_search(namespace) {
93 let filter = ListFilter {
94 namespace: Some(ns.to_string()),
95 ..Default::default()
96 };
97 for list in find_lists(t, &filter).await? {
98 keys.push(list.key()?);
99 }
100 }
101 keys.sort();
102 keys.dedup();
103 Ok(keys)
104}
105
106async fn resolve_selection(
107 t: &Transport,
108 selectors: &[String],
109 tag: Option<&str>,
110 namespace: Option<&str>,
111 noun: &str,
112) -> Result<Option<Vec<ListKey>>> {
113 if selectors.is_empty() && tag.is_none() {
114 return Ok(None);
115 }
116
117 let mut keys = Vec::new();
118 for s in selectors {
119 keys.push(resolve_list_key(t, s, namespace).await?);
120 }
121
122 let mut tag_matched = false;
123 if let Some(tag) = tag {
124 for ns in namespaces_to_search(namespace) {
125 let filter = ListFilter {
126 tag: Some(tag.to_string()),
127 namespace: Some(ns.to_string()),
128 ..Default::default()
129 };
130 for list in find_lists(t, &filter).await? {
131 tag_matched = true;
132 keys.push(list.key()?);
133 }
134 }
135 if !tag_matched {
136 return Err(Error::new(
137 ErrorKind::NotFound,
138 format!("No exception lists matched tag '{tag}'; nothing to {noun}"),
139 ));
140 }
141 }
142
143 keys.sort();
144 keys.dedup();
145 Ok(Some(keys))
146}
147
148pub async fn list_op(t: &Transport, f: &ListFilter) -> Result<ListReport> {
149 let mut lists = Vec::new();
150 if f.namespace.is_some() {
151 lists = find_lists(t, f).await?;
152 } else {
153 for ns in NAMESPACES {
154 let mut per_ns = f.clone();
155 per_ns.namespace = Some(ns.to_string());
156 lists.extend(find_lists(t, &per_ns).await?);
157 }
158 }
159 normalize::sort_lists(&mut lists);
160 let total = lists.len();
161 Ok(ListReport { total, lists })
162}
163
164pub async fn get_op(t: &Transport, list_id: &str, namespace: Option<&str>) -> Result<ListDetail> {
165 let key = resolve_list_key(t, list_id, namespace).await?;
166 let list = get_list(t, &key).await?;
167 let items = find_items(t, &key).await?;
168 Ok(ListDetail { list, items })
169}
170
171pub fn validate_op(path: &Path) -> Result<Bundle> {
172 let body = std::fs::read_to_string(path)
173 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
174 if Format::from_path(path) == Format::Yaml {
175 return Err(Error::new(
176 ErrorKind::Unsupported,
177 format!(
178 "{} is YAML, which cannot represent exception lists or items; validate an NDJSON bundle",
179 path.display()
180 ),
181 ));
182 }
183 codec::decode_bundle(&body)
184}
185
186pub async fn export_op(
187 t: &Transport,
188 list_ids: &[String],
189 tag: Option<&str>,
190 namespace: Option<&str>,
191 format: Format,
192) -> Result<ExportOutcome> {
193 if format == Format::Yaml {
194 return Err(Error::new(
195 ErrorKind::Unsupported,
196 "exception bundles have no YAML form; re-run with --format-file ndjson",
197 ));
198 }
199 let keys = match resolve_selection(t, list_ids, tag, namespace, "export").await? {
200 Some(keys) => keys,
201 None => all_list_keys(t, namespace).await?,
202 };
203 export_lists(t, &keys).await
204}
205
206#[derive(Debug, Clone, PartialEq)]
207pub struct DeletePlan {
208 pub preview: MutationPlan,
209 pub keys: Vec<ListKey>,
210}
211
212pub async fn plan_delete_op(
213 t: &Transport,
214 list_ids: &[String],
215 namespace: Option<&str>,
216) -> Result<DeletePlan> {
217 let mut resolved = BTreeSet::new();
218 for id in list_ids {
219 resolved.insert(resolve_list_key(t, id, namespace).await?);
220 }
221 let keys: Vec<_> = resolved.into_iter().collect();
222 let targets: Vec<_> = keys
223 .iter()
224 .map(|key| format!("{} ({})", key.list_id, key.namespace_type))
225 .collect();
226 Ok(DeletePlan {
227 preview: MutationPlan {
228 preview_action: format!("Delete {} exception list(s)", targets.len()),
229 preview_details: targets.clone(),
230 targets,
231 },
232 keys,
233 })
234}
235
236pub async fn apply_delete_op(t: &Transport, plan: &DeletePlan) -> Result<DeleteOutcome> {
237 let mut deleted = Vec::new();
238 let mut failed = Vec::new();
239 for key in &plan.keys {
240 match delete_list(t, key).await {
241 Ok(_) => deleted.push(json!({
242 "list_id": key.list_id,
243 "namespace_type": key.namespace_type,
244 })),
245 Err(e) => failed.push(json!({
246 "list_id": key.list_id,
247 "namespace_type": key.namespace_type,
248 "error": e.message,
249 })),
250 }
251 }
252 Ok(DeleteOutcome {
253 applied: true,
254 deleted,
255 failed,
256 total: plan.keys.len(),
257 })
258}
259
260pub async fn plan_import_op(
261 t: Option<&Transport>,
262 path: &Path,
263 overwrite: bool,
264 skip_existing: bool,
265) -> Result<ImportPlan> {
266 let body = std::fs::read_to_string(path)
267 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
268
269 if Format::from_path(path) == Format::Yaml {
270 return Err(Error::new(
271 ErrorKind::Unsupported,
272 format!(
273 "{} is YAML, which cannot represent exception lists or items; import an NDJSON bundle",
274 path.display()
275 ),
276 ));
277 }
278
279 let bundle = codec::decode_bundle(&body)?;
280
281 if !bundle.rules.is_empty() {
282 return Err(Error::new(
283 ErrorKind::Unsupported,
284 format!(
285 "this file carries {} rule(s), which the exception import route cannot import; \
286 use `rules import` for a rules bundle",
287 bundle.rules.len()
288 ),
289 ));
290 }
291
292 for item in &bundle.items {
293 if item.list_id().is_err() {
294 return Err(Error::new(
295 ErrorKind::Error,
296 format!(
297 "an exception item ('{}') has no readable list_id",
298 item.item_id().unwrap_or("<unreadable>")
299 ),
300 ));
301 }
302 }
303
304 let total = bundle.lists.len() + bundle.items.len();
305 let mut lists = bundle.lists;
306 let mut items = bundle.items;
307 let mut skipped: Vec<Value> = Vec::new();
308
309 if skip_existing {
310 let t = t.ok_or_else(|| {
311 Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
312 })?;
313 let keys: Vec<ListKey> = lists
314 .iter()
315 .map(ExceptionList::key)
316 .collect::<Result<_>>()?;
317 let existing = resolve_ids(t, &keys).await?;
318
319 let mut keep = Vec::with_capacity(lists.len());
320 let mut skip_keys = Vec::new();
321 for list in lists {
322 let key = list.key()?;
323 if existing.contains_key(&key) {
324 skipped.push(json!({
325 "list_id": key.list_id,
326 "namespace_type": key.namespace_type,
327 "reason": "exists",
328 }));
329 skip_keys.push(key);
330 } else {
331 keep.push(list);
332 }
333 }
334 lists = keep;
335
336 if !skip_keys.is_empty() {
337 let skip_set: std::collections::BTreeSet<ListKey> = skip_keys.into_iter().collect();
338 items.retain(|i| {
339 let key = ListKey {
340 list_id: i.list_id().expect("list_id validated above").to_string(),
341 namespace_type: i.namespace_type().to_string(),
342 };
343 !skip_set.contains(&key)
344 });
345 }
346 }
347
348 let mut details = Vec::with_capacity(lists.len() + items.len() + skipped.len());
349 for l in &lists {
350 details.push(format!("{} {} import", l.list_id()?, l.name()));
351 }
352 for i in &items {
353 details.push(format!("{} {} import", i.item_id()?, i.list_id()?));
354 }
355 details.extend(skipped.iter().map(|s| {
356 format!(
357 "{} skip (already exists)",
358 s["list_id"].as_str().unwrap_or_default()
359 )
360 }));
361
362 let mut targets = Vec::with_capacity(lists.len() + items.len());
363 for l in &lists {
364 targets.push(l.list_id()?.to_string());
365 }
366 for i in &items {
367 targets.push(i.item_id()?.to_string());
368 }
369
370 let qualifier = if overwrite {
371 ", overwriting existing".to_string()
372 } else if skip_existing && !skipped.is_empty() {
373 format!(", skipping {} that already exist", skipped.len())
374 } else {
375 String::new()
376 };
377 let preview = MutationPlan {
378 preview_action: format!(
379 "Import {} exception list(s) and {} item(s) from {}{qualifier}",
380 lists.len(),
381 items.len(),
382 path.display()
383 ),
384 preview_details: details,
385 targets,
386 };
387
388 let ndjson = codec::encode_bundle(&Bundle {
389 rules: Vec::new(),
390 lists,
391 items,
392 summary: None,
393 })?;
394
395 Ok(ImportPlan {
396 preview,
397 ndjson,
398 total,
399 skipped,
400 })
401}
402
403pub async fn apply_import_op(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
404 if ndjson.is_empty() {
405 return Ok(ImportReport {
406 succeeded: json!(0),
407 failed: json!([]),
408 });
409 }
410
411 let response = import_lists(t, ndjson, overwrite).await?;
412 crate::ops::decode_import_report(&response, "exceptions")
413}