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