elasticctl_api/value_lists/
validation.rs1use 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}