Skip to main content

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}