Skip to main content

elasticctl_api/
exceptions.rs

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}