Skip to main content

elasticctl_api/value_lists/
validation.rs

1use elasticctl_core::{Error, ErrorKind, Result};
2use serde::Serialize;
3
4use super::{
5    LocalPair, StoredValues, ValueListType, ValueSet, canonical_value, decode_container,
6    decode_values, file_stem, invalid,
7};
8
9pub const MAX_VALUE_BYTES_WITH_LF: usize = 8_000_000;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum WarningKind {
14    NonCanonical,
15    Duplicates,
16    DashLimit,
17    EmptyReferenced,
18    RuleTypeLimit,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
22pub struct Warning {
23    pub kind: WarningKind,
24    pub message: String,
25}
26
27#[derive(Clone, Debug, Default)]
28pub struct Findings {
29    pub refusals: Vec<Error>,
30    pub warnings: Vec<Warning>,
31}
32
33#[derive(Clone, Debug)]
34pub struct LocalValidation {
35    pub pair: Option<LocalPair>,
36    pub findings: Findings,
37}
38
39pub fn validate_local_pair(stem: &str, yaml: &[u8], values: Option<&[u8]>) -> LocalValidation {
40    let mut findings = Findings::default();
41    match decode_pair(stem, yaml, values) {
42        Ok(pair) => {
43            findings.warnings = value_warnings(&pair.container.list_type, &pair.values);
44            LocalValidation {
45                pair: Some(pair),
46                findings,
47            }
48        }
49        Err(error) => {
50            findings.refusals.push(error);
51            LocalValidation {
52                pair: None,
53                findings,
54            }
55        }
56    }
57}
58
59fn decode_pair(stem: &str, yaml: &[u8], values: Option<&[u8]>) -> Result<LocalPair> {
60    let yaml = std::str::from_utf8(yaml).map_err(|_| invalid("container file must be UTF-8"))?;
61    let container = decode_container(yaml)?;
62    super::stems::validate_id(&container.id)?;
63    if file_stem(&container.id) != stem {
64        return Err(invalid(format!(
65            "id {:?} requires file stem {:?}, found {stem:?}",
66            container.id,
67            file_stem(&container.id)
68        )));
69    }
70    container.list_type.require_supported()?;
71    let values = values.ok_or_else(|| invalid("sibling values file is missing"))?;
72    let values = std::str::from_utf8(values).map_err(|_| invalid("values file must be UTF-8"))?;
73    let values = decode_values(&container.list_type, values)?;
74    for value in &values {
75        if value.len() >= MAX_VALUE_BYTES_WITH_LF {
76            return Err(invalid(
77                "one value with its LF exceeds the 8,000,000-byte import bound",
78            ));
79        }
80    }
81    Ok(LocalPair {
82        stem: stem.to_owned(),
83        container,
84        values,
85    })
86}
87
88fn canonical_remedy(kind: &ValueListType, value: &str) -> Result<Option<String>> {
89    let canonical = canonical_value(kind, value)?;
90    if canonical.as_deref() == Some(value) {
91        return Ok(None);
92    }
93    Ok(Some(match canonical {
94        Some(canonical) => format!("non-canonical value {value:?}; use {canonical:?}"),
95        None => format!(
96            "non-canonical mixed-family range {value:?}; use same-family ascending ranges or CIDR blocks"
97        ),
98    }))
99}
100
101pub fn validate_additions(kind: &ValueListType, additions: &ValueSet) -> Findings {
102    let mut findings = Findings::default();
103    if let Err(error) = kind.require_supported() {
104        findings.refusals.push(error);
105        return findings;
106    }
107    for value in additions {
108        if value.len() >= MAX_VALUE_BYTES_WITH_LF {
109            findings.refusals.push(invalid(
110                "one value with its LF exceeds the 8,000,000-byte import bound",
111            ));
112        }
113        match canonical_remedy(kind, value) {
114            Ok(Some(message)) => findings.refusals.push(invalid(message)),
115            Err(error) => findings.refusals.push(error),
116            Ok(None) => {}
117        }
118    }
119    findings
120}
121
122fn value_warnings(kind: &ValueListType, values: &ValueSet) -> Vec<Warning> {
123    let mut warnings = noncanonical_warnings(kind, values);
124    if let Some(warning) = size_warning(kind, values.iter().map(String::as_str)) {
125        warnings.push(warning);
126    }
127    warnings
128}
129
130fn noncanonical_warnings(kind: &ValueListType, values: &ValueSet) -> Vec<Warning> {
131    let mut warnings = Vec::new();
132    for value in values {
133        if let Ok(Some(message)) = canonical_remedy(kind, value) {
134            warnings.push(Warning {
135                kind: WarningKind::NonCanonical,
136                message,
137            });
138        }
139    }
140    warnings
141}
142
143pub fn stored_warnings(kind: &ValueListType, stored: &StoredValues) -> Vec<Warning> {
144    let set = stored.set();
145    let mut warnings = noncanonical_warnings(kind, &set);
146    if let Some(warning) = size_warning(kind, stored.values.iter().map(String::as_str)) {
147        warnings.push(warning);
148    }
149    if stored.values.len() != set.len() {
150        warnings.push(Warning {
151            kind: WarningKind::Duplicates,
152            message: format!(
153                "{} stored values have {} distinct spellings; duplicates are retained",
154                stored.values.len(),
155                set.len()
156            ),
157        });
158    }
159    if *kind == ValueListType::IpRange && stored.dash_count() > 200 {
160        warnings.push(Warning {
161            kind: WarningKind::DashLimit,
162            message: dash_message("current", stored.dash_count()),
163        });
164    }
165    warnings
166}
167
168pub fn size_warning<'a>(
169    kind: &ValueListType,
170    values: impl IntoIterator<Item = &'a str>,
171) -> Option<Warning> {
172    let count = values
173        .into_iter()
174        .filter(|value| *kind != ValueListType::IpRange || value.contains('/'))
175        .count();
176    let limited = match kind {
177        ValueListType::Text => true,
178        ValueListType::Keyword | ValueListType::Ip | ValueListType::IpRange => count >= 65_536,
179        ValueListType::Unsupported(_) => false,
180    };
181    limited.then(|| Warning { kind: WarningKind::RuleTypeLimit, message: format!("{kind} list ({count} values) works only with custom query, saved query, machine learning, and indicator match rules") })
182}
183
184#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub enum PhaseOrder {
187    AddsFirst,
188    RemovesFirst,
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct ProjectedStates {
193    pub current: StoredValues,
194    pub between: StoredValues,
195    pub final_values: StoredValues,
196}
197
198pub fn project_states(
199    current: &StoredValues,
200    adds: &ValueSet,
201    removes: &ValueSet,
202    order: PhaseOrder,
203) -> ProjectedStates {
204    let remove = |stored: &StoredValues| {
205        StoredValues::new(
206            stored
207                .values
208                .iter()
209                .filter(|v| !removes.contains(*v))
210                .cloned()
211                .collect(),
212        )
213    };
214    let add = |stored: &StoredValues| {
215        let present = stored.set();
216        let mut values = stored.values.clone();
217        values.extend(adds.difference(&present).cloned());
218        StoredValues::new(values)
219    };
220    let (between, final_values) = match order {
221        PhaseOrder::AddsFirst => {
222            let between = add(current);
223            let final_values = remove(&between);
224            (between, final_values)
225        }
226        PhaseOrder::RemovesFirst => {
227            let between = remove(current);
228            let final_values = add(&between);
229            (between, final_values)
230        }
231    };
232    ProjectedStates {
233        current: current.clone(),
234        between,
235        final_values,
236    }
237}
238
239#[derive(Clone, Debug, Default)]
240pub struct ReferenceUse {
241    pub installed: Vec<String>,
242    pub desired: Vec<String>,
243    pub creating: bool,
244}
245
246fn dash_message(state: &str, count: usize) -> String {
247    format!(
248        "{state} state has {count} stored dash values, above 200: Kibana 9.5.1, 9.5.6, and 9.7.0 suppress every event for an exception on this list with either operator; write a single address as a/32 or a/128 and a range as CIDR blocks"
249    )
250}
251
252pub fn validate_projected(
253    kind: &ValueListType,
254    states: &ProjectedStates,
255    references: &ReferenceUse,
256) -> Findings {
257    let mut findings = Findings::default();
258    if *kind == ValueListType::IpRange {
259        let current = states.current.dash_count();
260        if current > 200 {
261            findings.warnings.push(Warning {
262                kind: WarningKind::DashLimit,
263                message: dash_message("current", current),
264            });
265        }
266        for (phase, count, allowed) in [
267            (
268                "between-phases",
269                states.between.dash_count(),
270                current.max(200),
271            ),
272            ("final", states.final_values.dash_count(), 200),
273        ] {
274            if count > allowed {
275                findings.refusals.push(Error::new(
276                    ErrorKind::Unsupported,
277                    dash_message(phase, count),
278                ));
279            }
280        }
281    }
282    let names: ValueSet = references
283        .installed
284        .iter()
285        .chain(&references.desired)
286        .cloned()
287        .collect();
288    if !names.is_empty() {
289        let reason = format!(
290            "an empty list under is in list suppresses every event; references: {}",
291            names.into_iter().collect::<Vec<_>>().join(", ")
292        );
293        if references.creating && !references.installed.is_empty() {
294            findings.refusals.push(Error::new(
295                ErrorKind::Unsupported,
296                format!("cannot create a list already named by installed references: {reason}"),
297            ));
298        }
299        if states.current.values.is_empty() && !references.creating {
300            findings.warnings.push(Warning {
301                kind: WarningKind::EmptyReferenced,
302                message: format!("current state is empty: {reason}"),
303            });
304        }
305        for (phase, values) in [
306            ("between-phases", &states.between),
307            ("final", &states.final_values),
308        ] {
309            if values.values.is_empty() {
310                findings.refusals.push(Error::new(
311                    ErrorKind::Unsupported,
312                    format!("{phase} state is empty: {reason}"),
313                ));
314            }
315        }
316    }
317    findings
318}