Skip to main content

elasticctl_api/
exceptions.rs

1//! Typed wrappers for the exception-list API.
2//!
3//! Functions use the stable `list_id` plus `namespace_type` identity, never the
4//! volatile saved-object `id` (spec 4.5). The one exception is `export_lists`,
5//! which fetches `id` at the single boundary where the route demands it.
6
7use crate::codec::{self, Bundle, Format};
8use crate::model::{ExceptionItem, ExceptionList, ListKey};
9use crate::normalize;
10use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
11use crate::rules::kql_escape;
12use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18const BASE: &str = "/api/exception_lists";
19const ITEMS: &str = "/api/exception_lists/items";
20
21/// A stable value-list identity referenced by an exception item.
22///
23/// Unlike exception containers, a value list has no namespace in the public
24/// lookup API. Its caller-supplied `id` is therefore the whole identity.
25#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
26pub(crate) struct ValueListRef {
27    pub id: String,
28}
29
30/// Elasticsearch's `_find` result window, shared with rules: `from + size` must
31/// not exceed 10,000. A server that caps `per_page` lower simply returns fewer
32/// objects with a larger `total`, which the paging loop handles.
33const RESULT_WINDOW: u32 = 10_000;
34
35#[derive(Debug, Clone, Default)]
36pub struct ListFilter {
37    pub list_type: Option<String>,
38    pub tag: Option<String>,
39    pub namespace: Option<String>,
40}
41
42impl ListFilter {
43    /// The KQL `filter` for this selection, or `None` when nothing filters.
44    ///
45    /// The list `_find` route filters over the namespace's saved-object type,
46    /// not the rules vertical's `alert.attributes.*` (measured, spec 7.7).
47    /// Values are quoted and escaped like `rules::to_kql`.
48    pub fn to_kql(&self) -> Option<String> {
49        let object_type = match self.namespace.as_deref() {
50            Some("agnostic") => "exception-list-agnostic",
51            _ => "exception-list",
52        };
53        let mut parts: Vec<String> = Vec::new();
54        if let Some(ty) = &self.list_type {
55            parts.push(format!(
56                "{object_type}.attributes.type: \"{}\"",
57                kql_escape(ty)
58            ));
59        }
60        if let Some(tag) = &self.tag {
61            parts.push(format!(
62                "{object_type}.attributes.tags: \"{}\"",
63                kql_escape(tag)
64            ));
65        }
66        (!parts.is_empty()).then(|| parts.join(" AND "))
67    }
68}
69
70/// Decode the shared `{data, page, per_page, total}` envelope.
71fn decode_find(body: &Value) -> Result<(Vec<Value>, u64)> {
72    let object = body.as_object().ok_or_else(|| {
73        Error::new(
74            ErrorKind::Http,
75            "invalid exception _find response: expected an object",
76        )
77    })?;
78    let data = object
79        .get("data")
80        .and_then(Value::as_array)
81        .cloned()
82        .ok_or_else(|| find_field_error("data", "an array"))?;
83    let page = required_positive_find_number(object, "page")?;
84    let per_page = required_positive_find_number(object, "per_page")?;
85    let total = object
86        .get("total")
87        .and_then(Value::as_u64)
88        .ok_or_else(|| find_field_error("total", "a non-negative integer"))?;
89
90    let returned = data.len() as u64;
91    if returned > per_page {
92        return Err(Error::new(
93            ErrorKind::Http,
94            format!(
95                "invalid exception _find response: page {page} returned {returned} objects, \
96                 exceeding per_page {per_page}"
97            ),
98        ));
99    }
100    if returned > total {
101        return Err(Error::new(
102            ErrorKind::Http,
103            format!(
104                "invalid exception _find response: page {page} returned {returned} objects, \
105                 exceeding total {total}"
106            ),
107        ));
108    }
109    Ok((data, total))
110}
111
112fn required_positive_find_number(
113    object: &serde_json::Map<String, Value>,
114    field: &str,
115) -> Result<u64> {
116    let value = object
117        .get(field)
118        .and_then(Value::as_u64)
119        .ok_or_else(|| find_field_error(field, "a positive integer"))?;
120    if value == 0 {
121        return Err(find_field_error(field, "a positive integer"));
122    }
123    Ok(value)
124}
125
126fn find_field_error(field: &str, expected: &str) -> Error {
127    Error::new(
128        ErrorKind::Http,
129        format!("invalid exception _find response field {field}: expected {expected}"),
130    )
131}
132
133/// Read every object a `_find` route serves, paging until `total` is reached.
134///
135/// Refuses a page that returns nothing while `total` is still ahead: a short
136/// read is indistinguishable from objects deleted between pages, and a mirror
137/// built on it would silently drop them.
138async fn find_paged(t: &Transport, path_for: impl Fn(u32) -> String) -> Result<Vec<Value>> {
139    t.require_feature(Feature::ExceptionLists).await?;
140    let mut out: Vec<Value> = Vec::new();
141    let mut page = 1u32;
142    loop {
143        let body = t.get(&path_for(page)).await?;
144        let (data, total) = decode_find(&body)?;
145        let before = out.len();
146        out.extend(data);
147        if (out.len() as u64) >= total {
148            return Ok(out);
149        }
150        if out.len() == before {
151            return Err(short_read(total, out.len()));
152        }
153        page += 1;
154    }
155}
156
157fn short_read(counted: u64, returned: usize) -> Error {
158    Error::new(
159        ErrorKind::Http,
160        format!(
161            "the server counted {counted} objects and returned {returned}. Refusing a partial \
162             read: a short read is indistinguishable from objects having been deleted."
163        ),
164    )
165}
166
167pub async fn find_lists(t: &Transport, f: &ListFilter) -> Result<Vec<ExceptionList>> {
168    let kql = f.to_kql();
169    let values = find_paged(t, |page| {
170        let mut path = format!("{BASE}/_find?page={page}&per_page={RESULT_WINDOW}");
171        if let Some(ns) = &f.namespace {
172            path.push_str(&format!("&namespace_type={}", urlencode(ns)));
173        }
174        // An empty filter is a 400 (measured, spec 7.7), so it is omitted,
175        // never sent as `filter=`.
176        if let Some(k) = &kql {
177            path.push_str(&format!("&filter={}", urlencode(k)));
178        }
179        path
180    })
181    .await?;
182
183    values.into_iter().map(ExceptionList::from_value).collect()
184}
185
186pub async fn get_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
187    t.require_feature(Feature::ExceptionLists).await?;
188    let body = t
189        .get(&format!(
190            "{BASE}?list_id={}&namespace_type={}",
191            urlencode(&key.list_id),
192            urlencode(&key.namespace_type),
193        ))
194        .await?;
195    ExceptionList::from_value(body)
196}
197
198pub async fn find_items(t: &Transport, key: &ListKey) -> Result<Vec<ExceptionItem>> {
199    let values = find_paged(t, |page| {
200        format!(
201            "{ITEMS}/_find?list_id={}&namespace_type={}&page={page}&per_page={RESULT_WINDOW}",
202            urlencode(&key.list_id),
203            urlencode(&key.namespace_type),
204        )
205    })
206    .await?;
207
208    values.into_iter().map(ExceptionItem::from_value).collect()
209}
210
211/// Whether the value-list data streams (`.lists-default`, `.items-default`)
212/// are bootstrapped.
213///
214/// `GET /api/lists/index` answers 404 when they are not, so a 404 is the
215/// absent case rather than an error (spec 7.7). Only a status outside the
216/// success range other than 404 is returned as an error. `pub(crate)` because
217/// it is shared by `doctor` and the push preview, not part of the public API.
218pub(crate) async fn value_lists_bootstrapped(t: &Transport) -> Result<bool> {
219    match t.get("/api/lists/index").await {
220        Ok(body) => {
221            let object = body.as_object();
222            let list_index = object
223                .and_then(|value| value.get("list_index"))
224                .and_then(Value::as_bool)
225                .ok_or_else(|| value_list_index_field_error("list_index"))?;
226            let list_item_index = object
227                .and_then(|value| value.get("list_item_index"))
228                .and_then(Value::as_bool)
229                .ok_or_else(|| value_list_index_field_error("list_item_index"))?;
230            Ok(list_index && list_item_index)
231        }
232        Err(e) if e.kind == ErrorKind::NotFound => Ok(false),
233        Err(e) => Err(e),
234    }
235}
236
237fn value_list_index_field_error(field: &str) -> Error {
238    Error::new(
239        ErrorKind::Http,
240        format!("invalid value-list index response field {field}: expected a boolean"),
241    )
242}
243
244/// Whether a value list with caller-supplied stable `id` exists.
245///
246/// The public route's successful response shape is server-owned and may grow,
247/// so the status alone is the contract. Only the measured 404 means absence.
248pub(crate) async fn value_list_exists(t: &Transport, id: &str) -> Result<bool> {
249    match t.get(&format!("/api/lists?id={}", urlencode(id))).await {
250        Ok(_) => Ok(true),
251        Err(e) if e.kind == ErrorKind::NotFound => Ok(false),
252        Err(e) => Err(e),
253    }
254}
255
256/// Map each key to the live container `id` on this stack. A key with no live
257/// container is absent from the map rather than mapped to a placeholder, so
258/// callers distinguish "exists here with this id" from "does not exist here".
259pub async fn resolve_ids(t: &Transport, keys: &[ListKey]) -> Result<BTreeMap<ListKey, String>> {
260    let mut map = BTreeMap::new();
261    for key in keys {
262        match get_list(t, key).await {
263            Ok(list) => {
264                if let Some(id) = list.as_map().get("id").and_then(Value::as_str) {
265                    map.insert(key.clone(), id.to_string());
266                }
267            }
268            Err(e) if e.kind == ErrorKind::NotFound => {}
269            Err(e) => return Err(e),
270        }
271    }
272    Ok(map)
273}
274
275pub async fn create_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
276    t.require_feature(Feature::ExceptionLists).await?;
277    let payload = normalize::canonical_list(l);
278    let response = t.post(BASE, Some(&payload.into_value())).await?;
279    ExceptionList::from_value(response)
280}
281
282pub async fn update_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
283    t.require_feature(Feature::ExceptionLists).await?;
284    let payload = normalize::canonical_list(l);
285    let response = t.put(BASE, &payload.into_value()).await?;
286    ExceptionList::from_value(response)
287}
288
289pub async fn delete_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
290    t.require_feature(Feature::ExceptionLists).await?;
291    let body = t
292        .delete(&format!(
293            "{BASE}?list_id={}&namespace_type={}",
294            urlencode(&key.list_id),
295            urlencode(&key.namespace_type),
296        ))
297        .await?;
298    ExceptionList::from_value(body)
299}
300
301pub async fn create_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
302    t.require_feature(Feature::ExceptionLists).await?;
303    let payload = normalize::canonical_item(i);
304    let response = t.post(ITEMS, Some(&payload.into_value())).await?;
305    ExceptionItem::from_value(response)
306}
307
308pub async fn update_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
309    t.require_feature(Feature::ExceptionLists).await?;
310    let payload = normalize::canonical_item(i);
311    let response = t.put(ITEMS, &payload.into_value()).await?;
312    ExceptionItem::from_value(response)
313}
314
315pub async fn delete_item(t: &Transport, item_id: &str, namespace: &str) -> Result<ExceptionItem> {
316    t.require_feature(Feature::ExceptionLists).await?;
317    let body = t
318        .delete(&format!(
319            "{ITEMS}?item_id={}&namespace_type={}",
320            urlencode(item_id),
321            urlencode(namespace),
322        ))
323        .await?;
324    ExceptionItem::from_value(body)
325}
326
327/// The required final line of one exception-list export response.
328///
329/// This deliberately does not reuse `ExportSummary`: that type defaults absent
330/// fields for importing historical bundles, while this live endpoint boundary
331/// must reject a response that does not state its measured outcome.
332#[derive(Deserialize)]
333struct ExceptionExportTrailer {
334    exported_exception_list_count: u64,
335    exported_exception_list_item_count: u64,
336    missing_exception_lists: Vec<Value>,
337    missing_exception_list_items: Vec<Value>,
338}
339
340struct DecodedExceptionExport {
341    exported_lists: u64,
342    missing: Vec<Value>,
343}
344
345fn decode_exception_export(body: &str, key: &ListKey) -> Result<DecodedExceptionExport> {
346    let trailer = body
347        .lines()
348        .rev()
349        .find(|line| !line.trim().is_empty())
350        .ok_or_else(|| Error::new(ErrorKind::Http, "missing exception export trailer"))?;
351    let trailer: ExceptionExportTrailer = serde_json::from_str(trailer).map_err(|e| {
352        Error::new(
353            ErrorKind::Http,
354            format!("invalid exception export trailer: {e}"),
355        )
356    })?;
357    if trailer.exported_exception_list_count > 1 {
358        return Err(Error::new(
359            ErrorKind::Http,
360            "contradictory exception export trailer: one request exported more than one list",
361        ));
362    }
363
364    let mut missing = trailer.missing_exception_lists;
365    missing.extend(trailer.missing_exception_list_items);
366    for value in &mut missing {
367        add_missing_identity(value, key);
368    }
369
370    // The item count is required even though one container export may carry
371    // any number of items. Destructure it so the wire contract remains
372    // explicit at this boundary.
373    let _ = trailer.exported_exception_list_item_count;
374    Ok(DecodedExceptionExport {
375        exported_lists: trailer.exported_exception_list_count,
376        missing,
377    })
378}
379
380fn add_missing_identity(value: &mut Value, key: &ListKey) {
381    if let Some(object) = value.as_object_mut() {
382        if !object.get("list_id").is_some_and(Value::is_string) {
383            object.insert("list_id".to_string(), Value::String(key.list_id.clone()));
384        }
385        if !object.get("namespace_type").is_some_and(Value::is_string) {
386            object.insert(
387                "namespace_type".to_string(),
388                Value::String(key.namespace_type.clone()),
389            );
390        }
391        return;
392    }
393
394    *value = json!({
395        "list_id": key.list_id,
396        "namespace_type": key.namespace_type,
397        "missing": std::mem::take(value),
398    });
399}
400
401/// Export the given containers and their items as NDJSON.
402///
403/// The export route is the one path that refuses `list_id` alone (measured,
404/// fact E), so each key is resolved to its live container `id` first. Identity
405/// stays `list_id` plus `namespace_type` everywhere; the `id` is fetched only
406/// here, at the boundary that demands it. A key with no live container is
407/// refused rather than skipped: a silently dropped key is a short export
408/// reported as a success.
409pub async fn export_lists(t: &Transport, keys: &[ListKey]) -> Result<ExportOutcome> {
410    t.require_feature(Feature::ExceptionLists).await?;
411    let ids = resolve_ids(t, keys).await?;
412
413    // Name every missing key at once, the way the mirror names every colliding
414    // filename pair: one refusal per run beats a re-run per missing key.
415    let missing: Vec<String> = keys
416        .iter()
417        .filter(|k| !ids.contains_key(*k))
418        .map(|k| format!("{} ({})", k.list_id, k.namespace_type))
419        .collect();
420    if !missing.is_empty() {
421        return Err(Error::new(
422            ErrorKind::NotFound,
423            format!("exception list not found: {}", missing.join(", ")),
424        ));
425    }
426
427    let mut body = String::new();
428    let mut exported = 0u64;
429    let mut missing = Vec::new();
430    for key in keys {
431        let id = ids.get(key).expect("every key resolved before export");
432        let path = format!(
433            "{BASE}/_export?id={}&list_id={}&namespace_type={}&include_expired_exceptions=true",
434            urlencode(id),
435            urlencode(&key.list_id),
436            urlencode(&key.namespace_type),
437        );
438        let response = t.post_text(&path, None).await?;
439        let decoded = decode_exception_export(&response, key)?;
440        exported = exported
441            .checked_add(decoded.exported_lists)
442            .ok_or_else(|| {
443                Error::new(
444                    ErrorKind::Http,
445                    "invalid exception export trailers: exported-list count overflow",
446                )
447            })?;
448        missing.extend(decoded.missing);
449        body.push_str(&response);
450        if !response.ends_with('\n') {
451            body.push('\n');
452        }
453    }
454    Ok(ExportOutcome {
455        body,
456        exported,
457        missing,
458    })
459}
460
461pub async fn import_lists(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
462    t.require_feature(Feature::ExceptionLists).await?;
463    t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
464        .await
465}
466
467/// The two namespaces a `list_id` can live in. Spec 4.5: `namespace_type` is
468/// half of a list's identity, so a command that scopes to one namespace and a
469/// command that scopes to the other read disjoint objects.
470const NAMESPACES: [&str; 2] = ["single", "agnostic"];
471
472/// The report `list` renders: every matching container, in stable order.
473#[derive(Debug, Clone, PartialEq, Serialize)]
474pub struct ListReport {
475    pub total: usize,
476    pub lists: Vec<ExceptionList>,
477}
478
479/// The report `get` renders: one container and every item inside it.
480#[derive(Debug, Clone, PartialEq, Serialize)]
481pub struct ListDetail {
482    pub list: ExceptionList,
483    pub items: Vec<ExceptionItem>,
484}
485
486/// The namespaces a command scoped by `--namespace` reads, or every namespace
487/// when the flag is absent.
488fn namespaces_to_search(namespace: Option<&str>) -> Vec<&str> {
489    match namespace {
490        Some(ns) => vec![ns],
491        None => NAMESPACES.to_vec(),
492    }
493}
494
495/// Resolve a `list_id` selector to its `ListKey`.
496///
497/// With `--namespace`, the selector is looked up in that namespace alone. A
498/// miss is `not_found` naming the selector. Without the flag, the namespace has
499/// to be found: a list that exists in neither is refused with `not_found`, and
500/// one that exists in both is refused with `conflict` naming `--namespace` as
501/// the remedy rather than silently picking a side (spec 4.5, 5.2).
502async fn resolve_list_key(
503    t: &Transport,
504    list_id: &str,
505    namespace: Option<&str>,
506) -> Result<ListKey> {
507    if let Some(ns) = namespace {
508        let key = ListKey {
509            list_id: list_id.to_string(),
510            namespace_type: ns.to_string(),
511        };
512        match get_list(t, &key).await {
513            Ok(_) => return Ok(key),
514            // Name the selector, not the raw server 404 (spec 4.3).
515            Err(e) if e.kind == ErrorKind::NotFound => {
516                return Err(Error::new(
517                    ErrorKind::NotFound,
518                    format!("exception list not found: {list_id} ({ns})"),
519                ));
520            }
521            Err(e) => return Err(e),
522        }
523    }
524
525    let mut matches = Vec::new();
526    for ns in NAMESPACES {
527        let key = ListKey {
528            list_id: list_id.to_string(),
529            namespace_type: ns.to_string(),
530        };
531        match get_list(t, &key).await {
532            Ok(_) => matches.push(key),
533            Err(e) if e.kind == ErrorKind::NotFound => {}
534            Err(e) => return Err(e),
535        }
536    }
537    match matches.len() {
538        1 => Ok(matches.pop().expect("one match")),
539        0 => Err(Error::new(
540            ErrorKind::NotFound,
541            format!("exception list not found: {list_id}"),
542        )),
543        _ => Err(Error::new(
544            ErrorKind::Conflict,
545            format!(
546                "exception list '{list_id}' exists in both the 'single' and 'agnostic' \
547                 namespaces; pass --namespace to select one"
548            ),
549        )),
550    }
551}
552
553/// Every live container's key, in the requested namespace or both.
554async fn all_list_keys(t: &Transport, namespace: Option<&str>) -> Result<Vec<ListKey>> {
555    let mut keys = Vec::new();
556    for ns in namespaces_to_search(namespace) {
557        let filter = ListFilter {
558            namespace: Some(ns.to_string()),
559            ..Default::default()
560        };
561        for list in find_lists(t, &filter).await? {
562            keys.push(list.key()?);
563        }
564    }
565    keys.sort();
566    keys.dedup();
567    Ok(keys)
568}
569
570/// Resolve selectors and an optional tag to list keys. `None` means "every
571/// list". A selector matching nothing is refused by `resolve_list_key`; a tag
572/// matching nothing is refused here even when a selector resolved (spec 4.3).
573async fn resolve_selection(
574    t: &Transport,
575    selectors: &[String],
576    tag: Option<&str>,
577    namespace: Option<&str>,
578    noun: &str,
579) -> Result<Option<Vec<ListKey>>> {
580    if selectors.is_empty() && tag.is_none() {
581        return Ok(None);
582    }
583
584    let mut keys = Vec::new();
585    for s in selectors {
586        keys.push(resolve_list_key(t, s, namespace).await?);
587    }
588
589    let mut tag_matched = false;
590    if let Some(tag) = tag {
591        for ns in namespaces_to_search(namespace) {
592            let filter = ListFilter {
593                tag: Some(tag.to_string()),
594                namespace: Some(ns.to_string()),
595                ..Default::default()
596            };
597            for list in find_lists(t, &filter).await? {
598                tag_matched = true;
599                keys.push(list.key()?);
600            }
601        }
602        if !tag_matched {
603            return Err(Error::new(
604                ErrorKind::NotFound,
605                format!("No exception lists matched tag '{tag}'; nothing to {noun}"),
606            ));
607        }
608    }
609
610    keys.sort();
611    keys.dedup();
612    Ok(Some(keys))
613}
614
615/// Every matching container. With no `--namespace`, both namespaces are read
616/// and merged so an `agnostic` container is never silently omitted: a list
617/// command that showed only half the namespace space would be lying (spec 5.2).
618pub async fn list_op(t: &Transport, f: &ListFilter) -> Result<ListReport> {
619    let mut lists = Vec::new();
620    if f.namespace.is_some() {
621        lists = find_lists(t, f).await?;
622    } else {
623        for ns in NAMESPACES {
624            let mut per_ns = f.clone();
625            per_ns.namespace = Some(ns.to_string());
626            lists.extend(find_lists(t, &per_ns).await?);
627        }
628    }
629    normalize::sort_lists(&mut lists);
630    let total = lists.len();
631    Ok(ListReport { total, lists })
632}
633
634/// Resolve a selector and fetch the container with all of its items.
635pub async fn get_op(t: &Transport, list_id: &str, namespace: Option<&str>) -> Result<ListDetail> {
636    let key = resolve_list_key(t, list_id, namespace).await?;
637    let list = get_list(t, &key).await?;
638    let items = find_items(t, &key).await?;
639    Ok(ListDetail { list, items })
640}
641
642/// Parse and validate a local file without contacting a server.
643///
644/// Exception bundles use NDJSON. YAML bundle input is unsupported, so YAML
645/// paths are refused rather than half-decoded.
646pub fn validate_op(path: &Path) -> Result<Bundle> {
647    let body = std::fs::read_to_string(path)
648        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
649    if Format::from_path(path) == Format::Yaml {
650        return Err(Error::new(
651            ErrorKind::Unsupported,
652            format!(
653                "{} is YAML, which cannot represent exception lists or items; validate an NDJSON bundle",
654                path.display()
655            ),
656        ));
657    }
658    codec::decode_bundle(&body)
659}
660
661/// Export the selected containers and their items.
662///
663/// The body is the raw `_export` concatenation, which `_import` accepts
664/// verbatim. YAML bundle export is unsupported.
665pub async fn export_op(
666    t: &Transport,
667    list_ids: &[String],
668    tag: Option<&str>,
669    namespace: Option<&str>,
670    format: Format,
671) -> Result<ExportOutcome> {
672    if format == Format::Yaml {
673        return Err(Error::new(
674            ErrorKind::Unsupported,
675            "exception bundles have no YAML form; re-run with --format-file ndjson",
676        ));
677    }
678    let keys = match resolve_selection(t, list_ids, tag, namespace, "export").await? {
679        Some(keys) => keys,
680        None => all_list_keys(t, namespace).await?,
681    };
682    export_lists(t, &keys).await
683}
684
685/// What `plan_delete_op` resolved, so the apply acts on exactly the keys the
686/// preview named rather than re-resolving a bare `list_id` after the guard.
687#[derive(Debug, Clone, PartialEq)]
688pub struct DeletePlan {
689    pub preview: MutationPlan,
690    pub keys: Vec<ListKey>,
691}
692
693/// Resolve every selector before previewing, so the preview is accurate and an
694/// unresolved selector fails before any write. The resolved keys travel with
695/// the plan so the apply cannot resolve a different namespace than the preview
696/// showed.
697pub async fn plan_delete_op(
698    t: &Transport,
699    list_ids: &[String],
700    namespace: Option<&str>,
701) -> Result<DeletePlan> {
702    let mut resolved = BTreeSet::new();
703    for id in list_ids {
704        resolved.insert(resolve_list_key(t, id, namespace).await?);
705    }
706    let keys: Vec<_> = resolved.into_iter().collect();
707    let targets: Vec<_> = keys
708        .iter()
709        .map(|key| format!("{} ({})", key.list_id, key.namespace_type))
710        .collect();
711    Ok(DeletePlan {
712        preview: MutationPlan {
713            preview_action: format!("Delete {} exception list(s)", targets.len()),
714            preview_details: targets.clone(),
715            targets,
716        },
717        keys,
718    })
719}
720
721/// Continue after per-container failures so the result records every deletion
722/// and every container that remains.
723pub async fn apply_delete_op(t: &Transport, plan: &DeletePlan) -> Result<DeleteOutcome> {
724    let mut deleted = Vec::new();
725    let mut failed = Vec::new();
726    for key in &plan.keys {
727        match delete_list(t, key).await {
728            Ok(_) => deleted.push(json!({
729                "list_id": key.list_id,
730                "namespace_type": key.namespace_type,
731            })),
732            Err(e) => failed.push(json!({
733                "list_id": key.list_id,
734                "namespace_type": key.namespace_type,
735                "error": e.message,
736            })),
737        }
738    }
739    Ok(DeleteOutcome {
740        applied: true,
741        deleted,
742        failed,
743        total: plan.keys.len(),
744    })
745}
746
747/// Compute the import preview and the NDJSON to upload.
748///
749/// The file is decoded as a bundle, never as rules only: `decode_ndjson` drops
750/// exception lines and would leave the operator with rules referencing lists
751/// that were never created. The preview counts containers and items, so an
752/// items-only file previews as the non-zero mutation it is. The transport is
753/// `None` unless `skip_existing` is set, so a dry run that only reads the file
754/// never needs a credential.
755pub async fn plan_import_op(
756    t: Option<&Transport>,
757    path: &Path,
758    overwrite: bool,
759    skip_existing: bool,
760) -> Result<ImportPlan> {
761    let body = std::fs::read_to_string(path)
762        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
763
764    if Format::from_path(path) == Format::Yaml {
765        return Err(Error::new(
766            ErrorKind::Unsupported,
767            format!(
768                "{} is YAML, which cannot represent exception lists or items; import an NDJSON bundle",
769                path.display()
770            ),
771        ));
772    }
773
774    let bundle = codec::decode_bundle(&body)?;
775
776    // The exception import route imports containers and items only. A file that
777    // also carries rules would drop them silently (spec 5.2).
778    if !bundle.rules.is_empty() {
779        return Err(Error::new(
780            ErrorKind::Unsupported,
781            format!(
782                "this file carries {} rule(s), which the exception import route cannot import; \
783                 use `rules import` for a rules bundle",
784                bundle.rules.len()
785            ),
786        ));
787    }
788
789    // An item whose list_id is unreadable has no home; uploading it would
790    // strand it (spec 5.2). Refuse before any skip decision so both paths share
791    // the guard instead of one silently defaulting to an empty list_id.
792    for item in &bundle.items {
793        if item.list_id().is_err() {
794            return Err(Error::new(
795                ErrorKind::Error,
796                format!(
797                    "an exception item ('{}') has no readable list_id",
798                    item.item_id().unwrap_or("<unreadable>")
799                ),
800            ));
801        }
802    }
803
804    let total = bundle.lists.len() + bundle.items.len();
805    let mut lists = bundle.lists;
806    let mut items = bundle.items;
807    let mut skipped: Vec<Value> = Vec::new();
808
809    if skip_existing {
810        let t = t.ok_or_else(|| {
811            Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
812        })?;
813        let keys: Vec<ListKey> = lists
814            .iter()
815            .map(ExceptionList::key)
816            .collect::<Result<_>>()?;
817        let existing = resolve_ids(t, &keys).await?;
818
819        let mut keep = Vec::with_capacity(lists.len());
820        let mut skip_keys = Vec::new();
821        for list in lists {
822            let key = list.key()?;
823            if existing.contains_key(&key) {
824                skipped.push(json!({
825                    "list_id": key.list_id,
826                    "namespace_type": key.namespace_type,
827                    "reason": "exists",
828                }));
829                skip_keys.push(key);
830            } else {
831                keep.push(list);
832            }
833        }
834        lists = keep;
835
836        // Items inside a skipped container are skipped with it: their home was
837        // never written, so writing them alone would strand them.
838        if !skip_keys.is_empty() {
839            let skip_set: std::collections::BTreeSet<ListKey> = skip_keys.into_iter().collect();
840            items.retain(|i| {
841                let key = ListKey {
842                    list_id: i.list_id().expect("list_id validated above").to_string(),
843                    namespace_type: i.namespace_type().to_string(),
844                };
845                !skip_set.contains(&key)
846            });
847        }
848    }
849
850    let mut details = Vec::with_capacity(lists.len() + items.len() + skipped.len());
851    for l in &lists {
852        details.push(format!("{}  {}  import", l.list_id()?, l.name()));
853    }
854    for i in &items {
855        details.push(format!("{}  {}  import", i.item_id()?, i.list_id()?));
856    }
857    details.extend(skipped.iter().map(|s| {
858        format!(
859            "{}  skip (already exists)",
860            s["list_id"].as_str().unwrap_or_default()
861        )
862    }));
863
864    let mut targets = Vec::with_capacity(lists.len() + items.len());
865    for l in &lists {
866        targets.push(l.list_id()?.to_string());
867    }
868    for i in &items {
869        targets.push(i.item_id()?.to_string());
870    }
871
872    let qualifier = if overwrite {
873        ", overwriting existing".to_string()
874    } else if skip_existing && !skipped.is_empty() {
875        format!(", skipping {} that already exist", skipped.len())
876    } else {
877        String::new()
878    };
879    let preview = MutationPlan {
880        preview_action: format!(
881            "Import {} exception list(s) and {} item(s) from {}{qualifier}",
882            lists.len(),
883            items.len(),
884            path.display()
885        ),
886        preview_details: details,
887        targets,
888    };
889
890    // Kibana's import accepts export trailers, but this plan may skip objects.
891    // Re-encode only the planned objects so stale trailer counts do not describe
892    // containers or items that will not be uploaded.
893    let ndjson = codec::encode_bundle(&Bundle {
894        rules: Vec::new(),
895        lists,
896        items,
897        summary: None,
898    })?;
899
900    Ok(ImportPlan {
901        preview,
902        ndjson,
903        total,
904        skipped,
905    })
906}
907
908/// Upload the NDJSON `plan_import_op` prepared.
909pub async fn apply_import_op(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
910    // Do not upload empty NDJSON when every container already exists.
911    if ndjson.is_empty() {
912        return Ok(ImportReport {
913            succeeded: json!(0),
914            failed: json!([]),
915        });
916    }
917
918    let response = import_lists(t, ndjson, overwrite).await?;
919    crate::ops::decode_import_report(&response, "exceptions")
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use elasticctl_core::Profile;
926    use wiremock::matchers::{method, path, query_param};
927    use wiremock::{Mock, MockServer, ResponseTemplate};
928
929    fn transport(server: &MockServer) -> Transport {
930        Transport::new(&Profile {
931            kibana_url: server.uri(),
932            es_url: None,
933            api_key: Some("essu_test".into()),
934            username: None,
935            password: None,
936            space: "default".into(),
937            verify: true,
938            timeout_secs: 5,
939        })
940        .unwrap()
941    }
942
943    /// A successful lookup confirms the requested stable value-list id exists,
944    /// even when the server returns extra document fields.
945    #[tokio::test]
946    async fn value_list_lookup_accepts_any_successful_response_body() {
947        let server = MockServer::start().await;
948        Mock::given(method("GET"))
949            .and(path("/api/lists"))
950            .and(query_param("id", "ip-allowlist"))
951            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
952                "id": "ip-allowlist", "extra": {"server": "owned"}
953            })))
954            .expect(1)
955            .mount(&server)
956            .await;
957
958        assert!(
959            value_list_exists(&transport(&server), "ip-allowlist")
960                .await
961                .unwrap()
962        );
963    }
964
965    /// The measured 404 is the only absence signal; it must not be conflated
966    /// with authentication, validation, rate-limit, or server errors.
967    #[tokio::test]
968    async fn value_list_lookup_treats_only_not_found_as_absent() {
969        let server = MockServer::start().await;
970        Mock::given(method("GET"))
971            .and(path("/api/lists"))
972            .and(query_param("id", "missing"))
973            .respond_with(ResponseTemplate::new(404).set_body_json(json!({
974                "message": "value list is absent"
975            })))
976            .expect(1)
977            .mount(&server)
978            .await;
979
980        assert!(
981            !value_list_exists(&transport(&server), "missing")
982                .await
983                .unwrap()
984        );
985    }
986
987    #[tokio::test]
988    async fn value_list_lookup_propagates_classified_errors_after_transport_retries() {
989        for (status, kind, expected_requests) in [
990            (400, ErrorKind::Http, 1),
991            (403, ErrorKind::Permission, 1),
992            (429, ErrorKind::Http, 3),
993            (500, ErrorKind::Http, 3),
994        ] {
995            let server = MockServer::start().await;
996            Mock::given(method("GET"))
997                .and(path("/api/lists"))
998                .and(query_param("id", "broken"))
999                .respond_with(ResponseTemplate::new(status).set_body_json(json!({
1000                    "message": format!("HTTP {status}")
1001                })))
1002                .expect(expected_requests)
1003                .mount(&server)
1004                .await;
1005
1006            let err = value_list_exists(&transport(&server), "broken")
1007                .await
1008                .unwrap_err();
1009            assert_eq!(err.kind, kind, "HTTP {status}");
1010            assert_eq!(err.http_status, Some(status), "HTTP {status}");
1011        }
1012    }
1013
1014    #[test]
1015    fn to_kql_is_none_when_nothing_filters() {
1016        assert_eq!(ListFilter::default().to_kql(), None);
1017    }
1018
1019    #[test]
1020    fn to_kql_filters_type_over_the_measured_prefix() {
1021        let f = ListFilter {
1022            list_type: Some("detection".into()),
1023            ..Default::default()
1024        };
1025        assert_eq!(
1026            f.to_kql().unwrap(),
1027            "exception-list.attributes.type: \"detection\""
1028        );
1029    }
1030
1031    #[test]
1032    fn to_kql_filters_tags_over_the_measured_prefix() {
1033        let f = ListFilter {
1034            tag: Some("alpha".into()),
1035            ..Default::default()
1036        };
1037        assert_eq!(
1038            f.to_kql().unwrap(),
1039            "exception-list.attributes.tags: \"alpha\""
1040        );
1041    }
1042
1043    #[test]
1044    fn to_kql_uses_the_agnostic_saved_object_type_for_that_namespace() {
1045        let f = ListFilter {
1046            tag: Some("alpha".into()),
1047            namespace: Some("agnostic".into()),
1048            ..Default::default()
1049        };
1050        assert_eq!(
1051            f.to_kql().unwrap(),
1052            "exception-list-agnostic.attributes.tags: \"alpha\""
1053        );
1054    }
1055
1056    #[test]
1057    fn to_kql_combines_clauses_with_and() {
1058        let f = ListFilter {
1059            list_type: Some("detection".into()),
1060            tag: Some("alpha".into()),
1061            ..Default::default()
1062        };
1063        assert_eq!(
1064            f.to_kql().unwrap(),
1065            "exception-list.attributes.type: \"detection\" AND \
1066             exception-list.attributes.tags: \"alpha\""
1067        );
1068    }
1069
1070    #[test]
1071    fn to_kql_escapes_a_quote_in_the_value() {
1072        let f = ListFilter {
1073            tag: Some("a\"b".into()),
1074            ..Default::default()
1075        };
1076        let kql = f.to_kql().unwrap();
1077        assert!(kql.contains("a\\\"b"), "{kql}");
1078    }
1079}