tellaro_query_language/mutators/mod.rs
1//! Mutator system for field transformations in TQL.
2//!
3//! Mutators transform field values during query evaluation, supporting operations like
4//! string manipulation, encoding/decoding, network operations, and enrichment lookups.
5
6pub mod dns;
7pub mod encoding;
8pub mod geoip;
9pub mod list;
10pub mod network;
11pub mod string_mutators;
12
13use crate::error::{Result, TqlError};
14use serde_json::Value as JsonValue;
15use std::collections::HashMap;
16
17/// Base trait for all mutators.
18///
19/// ## Parameter convention
20///
21/// Mutator parameters arrive as a `HashMap<String, JsonValue>`. Named arguments use
22/// their declared key (e.g., `"delimiter"`, `"find"`). Positional arguments are stored
23/// under their zero-based index as a string key: `"0"`, `"1"`, etc.
24///
25/// Use [`get_param`] to look up a parameter by named key first, falling back to the
26/// positional index. This lets users write either `| split(delimiter=',')` or
27/// `| split(',')` with the same result.
28pub trait Mutator: Send + Sync {
29 /// Apply the mutator to a value
30 ///
31 /// # Arguments
32 ///
33 /// * `field_name` - The name of the field being mutated
34 /// * `record` - The full record (for enrichment mutators)
35 /// * `value` - The value to transform
36 ///
37 /// # Returns
38 ///
39 /// The transformed value
40 fn apply(&self, field_name: &str, record: &JsonValue, value: &JsonValue) -> Result<JsonValue>;
41
42 /// Get the mutator name
43 fn name(&self) -> &str;
44
45 /// Check if this mutator is an enrichment mutator
46 ///
47 /// Enrichment mutators add data to the record (e.g., nslookup adds DNS data,
48 /// geoip adds geo location data). They return a special structure with
49 /// `_tql_enrichment` that the evaluator/post-processor uses to enrich records.
50 ///
51 /// # Returns
52 ///
53 /// `true` if this is an enrichment mutator, `false` otherwise
54 fn is_enrichment(&self) -> bool {
55 false
56 }
57
58 /// Does this mutator answer a BOOLEAN ABOUT its input, rather than
59 /// transforming it?
60 ///
61 /// The property that decides what `field | mutator` means when the query
62 /// carries NO operator. For a transforming mutator (`lowercase`, `trim`)
63 /// that spelling is a PROJECTION -- keep every record that has the field and
64 /// apply the mutator on the way out -- so it parses to `exists`. For a
65 /// predicate it is a FILTER, and must parse to `eq true`.
66 ///
67 /// `ip | is_loopback` parsed to `exists` in Rust for all five IP
68 /// predicates, so it matched every record that HAS an `ip` field at all
69 /// while reading as a filter. An unconditional-true clause dressed as a
70 /// predicate does not fail loudly; it just quietly stops filtering.
71 ///
72 /// DECLARED BY THE MUTATOR, not by a name list kept somewhere else. The
73 /// equivalent Python fact was a literal `["is_private", "is_global"]`
74 /// written out in three places in `parser.py`, and the three predicates
75 /// added later reached none of them -- the "set written by hand in N places,
76 /// correct in N-1" shape that `src/tql/mutator_classification.py` already
77 /// records twice. A new predicate overrides this one method and the parser
78 /// needs no edit.
79 fn returns_boolean(&self) -> bool {
80 false
81 }
82}
83
84/// Mutator parameters type
85pub type MutatorParams = HashMap<String, JsonValue>;
86
87/// Apply a sequence of mutators to a value
88///
89/// # Arguments
90///
91/// * `value` - The original value
92/// * `mutators` - A list of mutator instances
93/// * `field_name` - The name of the field being processed
94/// * `record` - The entire record (for enrichment mutators)
95///
96/// # Returns
97///
98/// The final mutated value
99pub fn apply_mutators(
100 value: &JsonValue,
101 mutators: &[Box<dyn Mutator>],
102 field_name: &str,
103 record: &JsonValue,
104) -> Result<JsonValue> {
105 let mut result = value.clone();
106
107 for mutator in mutators {
108 result = mutator.apply(field_name, record, &result)?;
109 }
110
111 Ok(result)
112}
113
114/// Look up a mutator parameter by named key first, then fall back to positional index.
115///
116/// Mutator parameters can be supplied as named args (`split(delimiter=',')`) or as
117/// positional args (`split(',')`). The parser stores positional args under string keys
118/// "0", "1", etc. (see `build_mutator_params` in `evaluator.rs`). This helper
119/// encapsulates the named-then-positional lookup convention so individual mutators
120/// don't have to duplicate the pattern.
121///
122/// # Arguments
123///
124/// * `params` - The parameter map
125/// * `named_key` - The named parameter key to try first (e.g., "delimiter")
126/// * `positional_index` - The positional index to try as fallback (e.g., 0 → key "0")
127///
128/// # Returns
129///
130/// A reference to the `JsonValue` if found by either key, or `None`
131pub fn get_param<'a>(
132 params: &'a HashMap<String, JsonValue>,
133 named_key: &str,
134 positional_index: usize,
135) -> Option<&'a JsonValue> {
136 params
137 .get(named_key)
138 .or_else(|| params.get(&positional_index.to_string()))
139}
140
141/// Every mutator name `create_mutator` accepts, in dispatch order.
142///
143/// This is the machine-readable registry of the Rust mutator surface. It exists so the
144/// registry can be observed from OUTSIDE the crate — by `tql --list-mutators`, and by the
145/// cross-language parity guard in `js/tests/registryParity.spec.ts` — instead of being
146/// hand-transcribed into a test file in another language, which is how the four-name
147/// Python gap in ui#590 stayed invisible.
148///
149/// It is NOT a second source of truth: `registry::mutator_names_matches_dispatch` parses
150/// the `create_mutator` match arms out of this very file and asserts set equality, and
151/// `registry::every_listed_name_constructs` asserts each entry actually builds. Adding a
152/// match arm without adding it here (or the reverse) fails `cargo test`.
153pub const MUTATOR_NAMES: &[&str] = &[
154 // String mutators
155 "lowercase",
156 "uppercase",
157 "trim",
158 "split",
159 "length",
160 "replace",
161 // Encoding mutators
162 "b64encode",
163 "b64decode",
164 "urldecode",
165 "hexencode",
166 "hexdecode",
167 "md5",
168 "sha256",
169 // Network/security mutators
170 "refang",
171 "defang",
172 "is_private",
173 "is_global",
174 "is_multicast",
175 "is_loopback",
176 "is_link_local",
177 // DNS mutators
178 "nslookup",
179 // GeoIP mutators
180 "geoip",
181 "geoip_lookup",
182 "geo",
183 // List mutators
184 "any",
185 "all",
186 "avg",
187 "average",
188 "sum",
189 "max",
190 "min",
191];
192
193/// Does the mutator called `name` answer a boolean ABOUT its input?
194///
195/// DERIVED by asking the mutator, so the set has exactly one definition:
196/// [`Mutator::returns_boolean`]. Nothing here enumerates predicate names, which
197/// is the whole point -- see that method for the defect this shape prevents.
198///
199/// An unknown name answers `false`: it is not this function's job to reject a
200/// bad mutator name, and the parser must not turn a typo into a filter.
201pub fn returns_boolean(name: &str) -> bool {
202 create_mutator(name, None).is_ok_and(|m| m.returns_boolean())
203}
204
205/// Every mutator name that answers a boolean about its input.
206///
207/// Derived from [`returns_boolean`], for tests and for cross-engine comparison
208/// against Python's `BOOLEAN_PREDICATE_MUTATORS`.
209pub fn boolean_predicate_names() -> Vec<&'static str> {
210 let mut names: Vec<&'static str> = MUTATOR_NAMES
211 .iter()
212 .copied()
213 .filter(|n| returns_boolean(n))
214 .collect();
215 names.sort_unstable();
216 names
217}
218
219/// Return every mutator name the evaluator accepts.
220///
221/// Sorted, so callers can compare sets without re-sorting. See [`MUTATOR_NAMES`].
222pub fn mutator_names() -> Vec<&'static str> {
223 let mut names = MUTATOR_NAMES.to_vec();
224 names.sort_unstable();
225 names
226}
227
228/// Create a mutator instance from a name and parameters
229///
230/// # Arguments
231///
232/// * `name` - The mutator name (case-insensitive)
233/// * `params` - Optional parameters as key-value pairs
234///
235/// # Returns
236///
237/// A boxed mutator instance
238///
239/// # Errors
240///
241/// Returns an error if the mutator is not recognized or parameters are invalid
242pub fn create_mutator(name: &str, params: Option<MutatorParams>) -> Result<Box<dyn Mutator>> {
243 let params = params.unwrap_or_default();
244 let name_lower = name.to_lowercase();
245
246 match name_lower.as_str() {
247 // String mutators
248 "lowercase" => Ok(Box::new(string_mutators::LowercaseMutator::new(params))),
249 "uppercase" => Ok(Box::new(string_mutators::UppercaseMutator::new(params))),
250 "trim" => Ok(Box::new(string_mutators::TrimMutator::new(params))),
251 "split" => Ok(Box::new(string_mutators::SplitMutator::new(params))),
252 "length" => Ok(Box::new(string_mutators::LengthMutator::new(params))),
253 "replace" => Ok(Box::new(string_mutators::ReplaceMutator::new(params))),
254
255 // Encoding mutators
256 "b64encode" => Ok(Box::new(encoding::Base64EncodeMutator::new(params))),
257 "b64decode" => Ok(Box::new(encoding::Base64DecodeMutator::new(params))),
258 "urldecode" => Ok(Box::new(encoding::URLDecodeMutator::new(params))),
259 "hexencode" => Ok(Box::new(encoding::HexEncodeMutator::new(params))),
260 "hexdecode" => Ok(Box::new(encoding::HexDecodeMutator::new(params))),
261 "md5" => Ok(Box::new(encoding::MD5Mutator::new(params))),
262 "sha256" => Ok(Box::new(encoding::SHA256Mutator::new(params))),
263
264 // Network/security mutators
265 "refang" => Ok(Box::new(network::RefangMutator::new(params))),
266 "defang" => Ok(Box::new(network::DefangMutator::new(params))),
267 "is_private" => Ok(Box::new(network::IsPrivateMutator::new(params))),
268 "is_global" => Ok(Box::new(network::IsGlobalMutator::new(params))),
269 "is_multicast" => Ok(Box::new(network::IsMulticastMutator::new(params))),
270 "is_loopback" => Ok(Box::new(network::IsLoopbackMutator::new(params))),
271 "is_link_local" => Ok(Box::new(network::IsLinkLocalMutator::new(params))),
272
273 // DNS mutators
274 "nslookup" => Ok(Box::new(dns::NSLookupMutator::new(params))),
275
276 // GeoIP mutators ("geo" is an alias used by Python and JS implementations)
277 "geoip" | "geoip_lookup" | "geo" => Ok(Box::new(geoip::GeoIPMutator::new(params))),
278
279 // List mutators
280 "any" => Ok(Box::new(list::AnyMutator::new(params))),
281 "all" => Ok(Box::new(list::AllMutator::new(params))),
282 "avg" => Ok(Box::new(list::AvgMutator::new(params))),
283 "average" => Ok(Box::new(list::AverageMutator::new(params))),
284 "sum" => Ok(Box::new(list::SumMutator::new(params))),
285 "max" => Ok(Box::new(list::MaxMutator::new(params))),
286 "min" => Ok(Box::new(list::MinMutator::new(params))),
287
288 // Future mutators will be added here
289 _ => Err(TqlError::MutatorError(format!("Unknown mutator: {}", name))),
290 }
291}
292
293#[cfg(test)]
294mod registry {
295 //! Binds [`MUTATOR_NAMES`] to the actual `create_mutator` dispatch.
296 //!
297 //! Without these, `MUTATOR_NAMES` would be exactly the kind of hand-maintained copy
298 //! this registry exists to abolish.
299
300 use super::*;
301
302 /// Extract the mutator names matched by `create_mutator`, from this file's own source.
303 ///
304 /// `include_str!` embeds the source at compile time, so this reads the dispatch that
305 /// was actually compiled — it cannot go stale relative to the binary under test.
306 fn dispatch_names_from_source() -> Vec<String> {
307 let src = include_str!("mod.rs");
308 let fn_start = src
309 .find("pub fn create_mutator(")
310 .expect("create_mutator not found in mod.rs");
311 let match_start = src[fn_start..]
312 .find("match name_lower.as_str() {")
313 .expect("create_mutator match block not found")
314 + fn_start;
315
316 let mut names = Vec::new();
317 for line in src[match_start..].lines() {
318 let line = line.trim();
319 if line.starts_with("//") {
320 continue;
321 }
322 if !line.contains("=>") {
323 continue;
324 }
325 let arm = line.split("=>").next().unwrap_or("");
326 // Pull every string literal on the left-hand side of the arm.
327 let mut rest = arm;
328 while let Some(open) = rest.find('"') {
329 let after = &rest[open + 1..];
330 let Some(close) = after.find('"') else { break };
331 names.push(after[..close].to_string());
332 rest = &after[close + 1..];
333 }
334 }
335 assert!(
336 !names.is_empty(),
337 "failed to parse any mutator names out of create_mutator"
338 );
339 names.sort();
340 names
341 }
342
343 #[test]
344 fn mutator_names_matches_dispatch() {
345 let from_source = dispatch_names_from_source();
346 let listed = mutator_names()
347 .into_iter()
348 .map(str::to_string)
349 .collect::<Vec<_>>();
350 assert_eq!(
351 listed, from_source,
352 "MUTATOR_NAMES has drifted from the create_mutator match arms. \
353 Add or remove the name in BOTH places (and in the Python + JS registries \
354 — see js/tests/registryParity.spec.ts)."
355 );
356 }
357
358 #[test]
359 fn every_listed_name_constructs() {
360 for name in MUTATOR_NAMES {
361 let built = create_mutator(name, None);
362 assert!(
363 built.is_ok(),
364 "MUTATOR_NAMES lists `{name}` but create_mutator rejects it"
365 );
366 }
367 }
368
369 #[test]
370 fn listed_names_are_unique_and_lowercase() {
371 let mut seen = std::collections::HashSet::new();
372 for name in MUTATOR_NAMES {
373 assert_eq!(
374 *name,
375 name.to_lowercase(),
376 "MUTATOR_NAMES entry `{name}` is not lowercase; create_mutator lowercases \
377 its input, so a mixed-case entry could never be matched"
378 );
379 assert!(
380 seen.insert(*name),
381 "duplicate entry `{name}` in MUTATOR_NAMES"
382 );
383 }
384 }
385
386 #[test]
387 fn unknown_mutator_is_rejected() {
388 assert!(
389 create_mutator("definitely_not_a_mutator", None).is_err(),
390 "create_mutator accepted a name that is not in the registry"
391 );
392 }
393}