Skip to main content

elasticctl_api/value_lists/
scan.rs

1use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5use super::{
6    ValueListType,
7    reads::{malformed, required_string},
8};
9use crate::{
10    exceptions,
11    model::{ExceptionItem, ListKey},
12};
13
14#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ReferenceOperator {
17    Included,
18    Excluded,
19}
20
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct InstalledReference {
23    pub container: ListKey,
24    pub item_id: String,
25    pub field: String,
26    pub operator: ReferenceOperator,
27    pub list_id: String,
28    pub list_type: ValueListType,
29}
30
31impl InstalledReference {
32    pub fn name(&self) -> String {
33        format!(
34            "{} ({}) item {} field {} {:?}",
35            self.container.list_id,
36            self.container.namespace_type,
37            self.item_id,
38            self.field,
39            self.operator
40        )
41    }
42}
43
44pub fn item_references(key: &ListKey, item: &ExceptionItem) -> Result<Vec<InstalledReference>> {
45    let body = Value::Object(item.as_map().clone());
46    if required_string(&body, "list_id")? != key.list_id
47        || required_string(&body, "namespace_type")? != key.namespace_type
48    {
49        return Err(malformed(
50            "exception item identity does not match its container",
51        ));
52    }
53    let item_id = required_string(&body, "item_id")?;
54    let entries = body
55        .get("entries")
56        .and_then(Value::as_array)
57        .ok_or_else(|| malformed(format!("exception item {item_id} has unreadable entries")))?;
58    let mut references = Vec::new();
59    for entry in entries {
60        let kind = required_string(entry, "type")?;
61        if kind != "list" {
62            continue;
63        }
64        let operator = match required_string(entry, "operator")? {
65            "included" => ReferenceOperator::Included,
66            "excluded" => ReferenceOperator::Excluded,
67            _ => {
68                return Err(malformed(format!(
69                    "exception item {item_id} has an unreadable list operator"
70                )));
71            }
72        };
73        let list = entry.get("list").ok_or_else(|| {
74            malformed(format!(
75                "exception item {item_id} has an unreadable list entry"
76            ))
77        })?;
78        references.push(InstalledReference {
79            container: key.clone(),
80            item_id: item_id.into(),
81            field: required_string(entry, "field")?.into(),
82            operator,
83            list_id: required_string(list, "id")?.into(),
84            list_type: required_string(list, "type")?.to_owned().into(),
85        });
86    }
87    Ok(references)
88}
89
90pub(super) fn sort_references(references: &mut [InstalledReference]) {
91    references.sort_by_cached_key(|r| {
92        (
93            r.container.clone(),
94            r.item_id.clone(),
95            r.field.clone(),
96            r.operator,
97            r.list_id.clone(),
98            r.list_type.to_string(),
99        )
100    });
101}
102
103pub async fn scan_references(t: &Transport) -> Result<Vec<InstalledReference>> {
104    t.require_feature(Feature::ExceptionLists).await?;
105    let mut references = Vec::new();
106    for namespace in ["single", "agnostic"] {
107        scan_namespace(t, namespace, &mut references).await?;
108    }
109    sort_references(&mut references);
110    Ok(references)
111}
112
113pub(super) async fn scan_namespace(
114    t: &Transport,
115    namespace: &str,
116    references: &mut Vec<InstalledReference>,
117) -> Result<()> {
118    let body = t
119        .get(&format!(
120            "/api/exception_lists/_find?page=1&per_page=10000&namespace_type={namespace}"
121        ))
122        .await?;
123    let lists = decode_scan_page(&body)?;
124    let mut seen = std::collections::BTreeSet::new();
125    for list in lists {
126        let key = ListKey {
127            list_id: required_string(&list, "list_id")?.into(),
128            namespace_type: required_string(&list, "namespace_type")?.into(),
129        };
130        if key.namespace_type != namespace || !seen.insert(key.clone()) {
131            return Err(malformed(
132                "invalid or duplicated exception container in reference scan",
133            ));
134        }
135        let read = exceptions::get_list(t, &key).await?;
136        if read.key()? != key {
137            return Err(malformed(
138                "exception container changed identity during reference scan",
139            ));
140        }
141        let body = t.get(&format!("/api/exception_lists/items/_find?list_id={}&namespace_type={}&page=1&per_page=10000", urlencode(&key.list_id), urlencode(&key.namespace_type))).await?;
142        let items = decode_scan_page(&body)?;
143        let mut seen_items = std::collections::BTreeSet::new();
144        for item in items {
145            let item = ExceptionItem::from_value(item)?;
146            if !seen_items.insert(item.item_id()?.to_owned()) {
147                return Err(malformed("duplicate exception item in reference scan"));
148            }
149            references.extend(item_references(&key, &item)?);
150        }
151    }
152    Ok(())
153}
154
155pub(super) fn decode_scan_page(body: &Value) -> Result<Vec<Value>> {
156    let total = body
157        .get("total")
158        .and_then(Value::as_u64)
159        .ok_or_else(|| malformed("invalid reference scan total"))?;
160    if total > 10_000 {
161        return Err(Error::new(
162            ErrorKind::Unsupported,
163            "reference scan exceeds the 10,000-object read window",
164        ));
165    }
166    let data = body
167        .get("data")
168        .and_then(Value::as_array)
169        .ok_or_else(|| malformed("invalid reference scan data"))?;
170    let per_page = body
171        .get("per_page")
172        .and_then(Value::as_u64)
173        .ok_or_else(|| malformed("invalid reference scan page size"))?;
174    if body.get("page").and_then(Value::as_u64) != Some(1)
175        || per_page == 0
176        || per_page > 10_000
177        || total > per_page
178        || data.len() as u64 != total
179    {
180        return Err(malformed(
181            "refusing incomplete or inconsistent reference scan page",
182        ));
183    }
184    Ok(data.clone())
185}