termivar_scanner/
anomaly.rs1use thiserror::Error;
16
17#[derive(Debug, Clone)]
19pub struct ErrorKeywordMatcher {
20 keywords: Vec<String>,
21 patterns: Vec<regex::Regex>,
22}
23
24impl ErrorKeywordMatcher {
25 #[must_use]
27 pub fn with_keywords(keywords: Vec<&str>) -> Self {
28 Self {
29 keywords: keywords.into_iter().map(str::to_owned).collect(),
30 patterns: Vec::new(),
31 }
32 }
33
34 pub fn with_patterns(patterns: Vec<&str>) -> Result<Self, String> {
36 let patterns = patterns
37 .into_iter()
38 .map(regex::Regex::new)
39 .collect::<Result<Vec<_>, _>>()
40 .map_err(|error| error.to_string())?;
41 Ok(Self {
42 keywords: Vec::new(),
43 patterns,
44 })
45 }
46
47 pub fn with_keywords_and_patterns(
49 keywords: Vec<&str>,
50 patterns: Vec<&str>,
51 ) -> Result<Self, String> {
52 let mut matcher = Self::with_patterns(patterns)?;
53 matcher.keywords = keywords.into_iter().map(str::to_owned).collect();
54 Ok(matcher)
55 }
56
57 #[must_use]
59 pub fn is_match(&self, text: &str) -> bool {
60 self.keywords.iter().any(|keyword| text.contains(keyword))
61 || self.patterns.iter().any(|pattern| pattern.is_match(text))
62 }
63
64 #[must_use]
66 pub fn match_count(&self, text: &str) -> usize {
67 self.keywords
68 .iter()
69 .filter(|keyword| text.contains(keyword.as_str()))
70 .count()
71 + self
72 .patterns
73 .iter()
74 .filter(|pattern| pattern.is_match(text))
75 .count()
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum DeviationDimension {
82 Timing,
83 ResponseSize,
84 TextMarker,
85 StatusCode,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct ResponseDeviation {
94 pub timing: f32,
95 pub response_size: f32,
96 pub text_marker: f32,
97 pub status_code: f32,
98}
99
100impl ResponseDeviation {
101 pub fn validate(&self) -> Result<(), ResponseDeviationValidationError> {
103 for (dimension, value) in [
104 (DeviationDimension::Timing, self.timing),
105 (DeviationDimension::ResponseSize, self.response_size),
106 (DeviationDimension::TextMarker, self.text_marker),
107 (DeviationDimension::StatusCode, self.status_code),
108 ] {
109 if !value.is_finite() || !(0.0..=1.0).contains(&value) {
110 return Err(ResponseDeviationValidationError::InvalidDimension { dimension });
111 }
112 }
113 Ok(())
114 }
115
116 pub fn dominant_dimension(
120 &self,
121 ) -> Result<Option<DeviationDimension>, ResponseDeviationValidationError> {
122 self.validate()?;
123 let mut dominant = None;
124 let mut maximum = 0.0_f32;
125 for (dimension, value) in [
126 (DeviationDimension::Timing, self.timing),
127 (DeviationDimension::ResponseSize, self.response_size),
128 (DeviationDimension::TextMarker, self.text_marker),
129 (DeviationDimension::StatusCode, self.status_code),
130 ] {
131 if value > maximum {
132 maximum = value;
133 dominant = Some(dimension);
134 }
135 }
136 Ok(dominant)
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
142pub enum ResponseDeviationValidationError {
143 #[error("response deviation dimension {dimension:?} must be finite and within 0..=1")]
144 InvalidDimension { dimension: DeviationDimension },
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn matcher_reports_only_literal_and_regex_matches() {
153 let matcher = ErrorKeywordMatcher::with_keywords_and_patterns(
154 vec!["literal-marker"],
155 vec![r"code-[0-9]+"],
156 )
157 .expect("valid pattern");
158
159 assert!(matcher.is_match("literal-marker and code-42"));
160 assert_eq!(matcher.match_count("literal-marker and code-42"), 2);
161 assert!(!matcher.is_match("ordinary text"));
162 }
163
164 #[test]
165 fn invalid_regex_is_rejected() {
166 assert!(ErrorKeywordMatcher::with_patterns(vec!["("]).is_err());
167 }
168
169 #[test]
170 fn deviation_vector_rejects_nonfinite_and_out_of_range_values() {
171 for invalid in [f32::NAN, f32::INFINITY, -0.1, 1.1] {
172 let vector = ResponseDeviation {
173 timing: invalid,
174 response_size: 0.0,
175 text_marker: 0.0,
176 status_code: 0.0,
177 };
178 assert_eq!(
179 vector.validate(),
180 Err(ResponseDeviationValidationError::InvalidDimension {
181 dimension: DeviationDimension::Timing,
182 })
183 );
184 }
185 }
186
187 #[test]
188 fn dominant_dimension_is_the_actual_maximum_with_stable_ties() {
189 let vector = ResponseDeviation {
190 timing: 0.2,
191 response_size: 0.7,
192 text_marker: 0.7,
193 status_code: 0.3,
194 };
195 assert_eq!(
196 vector.dominant_dimension(),
197 Ok(Some(DeviationDimension::ResponseSize))
198 );
199
200 let zero = ResponseDeviation {
201 timing: 0.0,
202 response_size: 0.0,
203 text_marker: 0.0,
204 status_code: 0.0,
205 };
206 assert_eq!(zero.dominant_dimension(), Ok(None));
207 }
208}