tellaro-query-language 3.0.1

A flexible, human-friendly query language for searching and filtering structured data
Documentation
//! Mutator system for field transformations in TQL.
//!
//! Mutators transform field values during query evaluation, supporting operations like
//! string manipulation, encoding/decoding, network operations, and enrichment lookups.

pub mod dns;
pub mod encoding;
pub mod geoip;
pub mod list;
pub mod network;
pub mod string_mutators;

use crate::error::{Result, TqlError};
use serde_json::Value as JsonValue;
use std::collections::HashMap;

/// Base trait for all mutators.
///
/// ## Parameter convention
///
/// Mutator parameters arrive as a `HashMap<String, JsonValue>`. Named arguments use
/// their declared key (e.g., `"delimiter"`, `"find"`). Positional arguments are stored
/// under their zero-based index as a string key: `"0"`, `"1"`, etc.
///
/// Use [`get_param`] to look up a parameter by named key first, falling back to the
/// positional index. This lets users write either `| split(delimiter=',')` or
/// `| split(',')` with the same result.
pub trait Mutator: Send + Sync {
    /// Apply the mutator to a value
    ///
    /// # Arguments
    ///
    /// * `field_name` - The name of the field being mutated
    /// * `record` - The full record (for enrichment mutators)
    /// * `value` - The value to transform
    ///
    /// # Returns
    ///
    /// The transformed value
    fn apply(&self, field_name: &str, record: &JsonValue, value: &JsonValue) -> Result<JsonValue>;

    /// Get the mutator name
    fn name(&self) -> &str;

    /// Check if this mutator is an enrichment mutator
    ///
    /// Enrichment mutators add data to the record (e.g., nslookup adds DNS data,
    /// geoip adds geo location data). They return a special structure with
    /// `_tql_enrichment` that the evaluator/post-processor uses to enrich records.
    ///
    /// # Returns
    ///
    /// `true` if this is an enrichment mutator, `false` otherwise
    fn is_enrichment(&self) -> bool {
        false
    }

    /// Does this mutator answer a BOOLEAN ABOUT its input, rather than
    /// transforming it?
    ///
    /// The property that decides what `field | mutator` means when the query
    /// carries NO operator. For a transforming mutator (`lowercase`, `trim`)
    /// that spelling is a PROJECTION -- keep every record that has the field and
    /// apply the mutator on the way out -- so it parses to `exists`. For a
    /// predicate it is a FILTER, and must parse to `eq true`.
    ///
    /// `ip | is_loopback` parsed to `exists` in Rust for all five IP
    /// predicates, so it matched every record that HAS an `ip` field at all
    /// while reading as a filter. An unconditional-true clause dressed as a
    /// predicate does not fail loudly; it just quietly stops filtering.
    ///
    /// DECLARED BY THE MUTATOR, not by a name list kept somewhere else. The
    /// equivalent Python fact was a literal `["is_private", "is_global"]`
    /// written out in three places in `parser.py`, and the three predicates
    /// added later reached none of them -- the "set written by hand in N places,
    /// correct in N-1" shape that `src/tql/mutator_classification.py` already
    /// records twice. A new predicate overrides this one method and the parser
    /// needs no edit.
    fn returns_boolean(&self) -> bool {
        false
    }
}

/// Mutator parameters type
pub type MutatorParams = HashMap<String, JsonValue>;

/// Apply a sequence of mutators to a value
///
/// # Arguments
///
/// * `value` - The original value
/// * `mutators` - A list of mutator instances
/// * `field_name` - The name of the field being processed
/// * `record` - The entire record (for enrichment mutators)
///
/// # Returns
///
/// The final mutated value
pub fn apply_mutators(
    value: &JsonValue,
    mutators: &[Box<dyn Mutator>],
    field_name: &str,
    record: &JsonValue,
) -> Result<JsonValue> {
    let mut result = value.clone();

    for mutator in mutators {
        result = mutator.apply(field_name, record, &result)?;
    }

    Ok(result)
}

/// Look up a mutator parameter by named key first, then fall back to positional index.
///
/// Mutator parameters can be supplied as named args (`split(delimiter=',')`) or as
/// positional args (`split(',')`). The parser stores positional args under string keys
/// "0", "1", etc. (see `build_mutator_params` in `evaluator.rs`). This helper
/// encapsulates the named-then-positional lookup convention so individual mutators
/// don't have to duplicate the pattern.
///
/// # Arguments
///
/// * `params` - The parameter map
/// * `named_key` - The named parameter key to try first (e.g., "delimiter")
/// * `positional_index` - The positional index to try as fallback (e.g., 0 → key "0")
///
/// # Returns
///
/// A reference to the `JsonValue` if found by either key, or `None`
pub fn get_param<'a>(
    params: &'a HashMap<String, JsonValue>,
    named_key: &str,
    positional_index: usize,
) -> Option<&'a JsonValue> {
    params
        .get(named_key)
        .or_else(|| params.get(&positional_index.to_string()))
}

/// Every mutator name `create_mutator` accepts, in dispatch order.
///
/// This is the machine-readable registry of the Rust mutator surface. It exists so the
/// registry can be observed from OUTSIDE the crate — by `tql --list-mutators`, and by the
/// cross-language parity guard in `js/tests/registryParity.spec.ts` — instead of being
/// hand-transcribed into a test file in another language, which is how the four-name
/// Python gap in ui#590 stayed invisible.
///
/// It is NOT a second source of truth: `registry::mutator_names_matches_dispatch` parses
/// the `create_mutator` match arms out of this very file and asserts set equality, and
/// `registry::every_listed_name_constructs` asserts each entry actually builds. Adding a
/// match arm without adding it here (or the reverse) fails `cargo test`.
pub const MUTATOR_NAMES: &[&str] = &[
    // String mutators
    "lowercase",
    "uppercase",
    "trim",
    "split",
    "length",
    "replace",
    // Encoding mutators
    "b64encode",
    "b64decode",
    "urldecode",
    "hexencode",
    "hexdecode",
    "md5",
    "sha256",
    // Network/security mutators
    "refang",
    "defang",
    "is_private",
    "is_global",
    "is_multicast",
    "is_loopback",
    "is_link_local",
    // DNS mutators
    "nslookup",
    // GeoIP mutators
    "geoip",
    "geoip_lookup",
    "geo",
    // List mutators
    "any",
    "all",
    "avg",
    "average",
    "sum",
    "max",
    "min",
];

/// Does the mutator called `name` answer a boolean ABOUT its input?
///
/// DERIVED by asking the mutator, so the set has exactly one definition:
/// [`Mutator::returns_boolean`]. Nothing here enumerates predicate names, which
/// is the whole point -- see that method for the defect this shape prevents.
///
/// An unknown name answers `false`: it is not this function's job to reject a
/// bad mutator name, and the parser must not turn a typo into a filter.
pub fn returns_boolean(name: &str) -> bool {
    create_mutator(name, None).is_ok_and(|m| m.returns_boolean())
}

/// Every mutator name that answers a boolean about its input.
///
/// Derived from [`returns_boolean`], for tests and for cross-engine comparison
/// against Python's `BOOLEAN_PREDICATE_MUTATORS`.
pub fn boolean_predicate_names() -> Vec<&'static str> {
    let mut names: Vec<&'static str> = MUTATOR_NAMES
        .iter()
        .copied()
        .filter(|n| returns_boolean(n))
        .collect();
    names.sort_unstable();
    names
}

/// Return every mutator name the evaluator accepts.
///
/// Sorted, so callers can compare sets without re-sorting. See [`MUTATOR_NAMES`].
pub fn mutator_names() -> Vec<&'static str> {
    let mut names = MUTATOR_NAMES.to_vec();
    names.sort_unstable();
    names
}

/// Create a mutator instance from a name and parameters
///
/// # Arguments
///
/// * `name` - The mutator name (case-insensitive)
/// * `params` - Optional parameters as key-value pairs
///
/// # Returns
///
/// A boxed mutator instance
///
/// # Errors
///
/// Returns an error if the mutator is not recognized or parameters are invalid
pub fn create_mutator(name: &str, params: Option<MutatorParams>) -> Result<Box<dyn Mutator>> {
    let params = params.unwrap_or_default();
    let name_lower = name.to_lowercase();

    match name_lower.as_str() {
        // String mutators
        "lowercase" => Ok(Box::new(string_mutators::LowercaseMutator::new(params))),
        "uppercase" => Ok(Box::new(string_mutators::UppercaseMutator::new(params))),
        "trim" => Ok(Box::new(string_mutators::TrimMutator::new(params))),
        "split" => Ok(Box::new(string_mutators::SplitMutator::new(params))),
        "length" => Ok(Box::new(string_mutators::LengthMutator::new(params))),
        "replace" => Ok(Box::new(string_mutators::ReplaceMutator::new(params))),

        // Encoding mutators
        "b64encode" => Ok(Box::new(encoding::Base64EncodeMutator::new(params))),
        "b64decode" => Ok(Box::new(encoding::Base64DecodeMutator::new(params))),
        "urldecode" => Ok(Box::new(encoding::URLDecodeMutator::new(params))),
        "hexencode" => Ok(Box::new(encoding::HexEncodeMutator::new(params))),
        "hexdecode" => Ok(Box::new(encoding::HexDecodeMutator::new(params))),
        "md5" => Ok(Box::new(encoding::MD5Mutator::new(params))),
        "sha256" => Ok(Box::new(encoding::SHA256Mutator::new(params))),

        // Network/security mutators
        "refang" => Ok(Box::new(network::RefangMutator::new(params))),
        "defang" => Ok(Box::new(network::DefangMutator::new(params))),
        "is_private" => Ok(Box::new(network::IsPrivateMutator::new(params))),
        "is_global" => Ok(Box::new(network::IsGlobalMutator::new(params))),
        "is_multicast" => Ok(Box::new(network::IsMulticastMutator::new(params))),
        "is_loopback" => Ok(Box::new(network::IsLoopbackMutator::new(params))),
        "is_link_local" => Ok(Box::new(network::IsLinkLocalMutator::new(params))),

        // DNS mutators
        "nslookup" => Ok(Box::new(dns::NSLookupMutator::new(params))),

        // GeoIP mutators ("geo" is an alias used by Python and JS implementations)
        "geoip" | "geoip_lookup" | "geo" => Ok(Box::new(geoip::GeoIPMutator::new(params))),

        // List mutators
        "any" => Ok(Box::new(list::AnyMutator::new(params))),
        "all" => Ok(Box::new(list::AllMutator::new(params))),
        "avg" => Ok(Box::new(list::AvgMutator::new(params))),
        "average" => Ok(Box::new(list::AverageMutator::new(params))),
        "sum" => Ok(Box::new(list::SumMutator::new(params))),
        "max" => Ok(Box::new(list::MaxMutator::new(params))),
        "min" => Ok(Box::new(list::MinMutator::new(params))),

        // Future mutators will be added here
        _ => Err(TqlError::MutatorError(format!("Unknown mutator: {}", name))),
    }
}

#[cfg(test)]
mod registry {
    //! Binds [`MUTATOR_NAMES`] to the actual `create_mutator` dispatch.
    //!
    //! Without these, `MUTATOR_NAMES` would be exactly the kind of hand-maintained copy
    //! this registry exists to abolish.

    use super::*;

    /// Extract the mutator names matched by `create_mutator`, from this file's own source.
    ///
    /// `include_str!` embeds the source at compile time, so this reads the dispatch that
    /// was actually compiled — it cannot go stale relative to the binary under test.
    fn dispatch_names_from_source() -> Vec<String> {
        let src = include_str!("mod.rs");
        let fn_start = src
            .find("pub fn create_mutator(")
            .expect("create_mutator not found in mod.rs");
        let match_start = src[fn_start..]
            .find("match name_lower.as_str() {")
            .expect("create_mutator match block not found")
            + fn_start;

        let mut names = Vec::new();
        for line in src[match_start..].lines() {
            let line = line.trim();
            if line.starts_with("//") {
                continue;
            }
            if !line.contains("=>") {
                continue;
            }
            let arm = line.split("=>").next().unwrap_or("");
            // Pull every string literal on the left-hand side of the arm.
            let mut rest = arm;
            while let Some(open) = rest.find('"') {
                let after = &rest[open + 1..];
                let Some(close) = after.find('"') else { break };
                names.push(after[..close].to_string());
                rest = &after[close + 1..];
            }
        }
        assert!(
            !names.is_empty(),
            "failed to parse any mutator names out of create_mutator"
        );
        names.sort();
        names
    }

    #[test]
    fn mutator_names_matches_dispatch() {
        let from_source = dispatch_names_from_source();
        let listed = mutator_names()
            .into_iter()
            .map(str::to_string)
            .collect::<Vec<_>>();
        assert_eq!(
            listed, from_source,
            "MUTATOR_NAMES has drifted from the create_mutator match arms. \
             Add or remove the name in BOTH places (and in the Python + JS registries \
             — see js/tests/registryParity.spec.ts)."
        );
    }

    #[test]
    fn every_listed_name_constructs() {
        for name in MUTATOR_NAMES {
            let built = create_mutator(name, None);
            assert!(
                built.is_ok(),
                "MUTATOR_NAMES lists `{name}` but create_mutator rejects it"
            );
        }
    }

    #[test]
    fn listed_names_are_unique_and_lowercase() {
        let mut seen = std::collections::HashSet::new();
        for name in MUTATOR_NAMES {
            assert_eq!(
                *name,
                name.to_lowercase(),
                "MUTATOR_NAMES entry `{name}` is not lowercase; create_mutator lowercases \
                 its input, so a mixed-case entry could never be matched"
            );
            assert!(
                seen.insert(*name),
                "duplicate entry `{name}` in MUTATOR_NAMES"
            );
        }
    }

    #[test]
    fn unknown_mutator_is_rejected() {
        assert!(
            create_mutator("definitely_not_a_mutator", None).is_err(),
            "create_mutator accepted a name that is not in the registry"
        );
    }
}