rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use regex::Regex;
use serde_json::{Map, Value};
use std::collections::HashMap;

const FILTERED: &str = "[FILTERED]";

/// A filter pattern used to redact sensitive parameter values.
#[derive(Debug, Clone)]
pub enum FilterPattern {
    /// Matches an exact key name or dotted path.
    Exact(String),
    /// Matches with a regular expression.
    Regex(Regex),
}

impl FilterPattern {
    /// Creates an exact-match filter pattern.
    #[must_use]
    pub fn exact(pattern: impl Into<String>) -> Self {
        Self::Exact(pattern.into())
    }

    /// Creates a regex-based filter pattern.
    pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
        Regex::new(pattern).map(Self::Regex)
    }
}

/// Redacts sensitive values from nested parameter hashes.
#[derive(Debug, Clone)]
pub struct ParameterFilter {
    patterns: Vec<FilterPattern>,
}

impl Default for ParameterFilter {
    fn default() -> Self {
        Self::new([
            FilterPattern::exact("password"),
            FilterPattern::exact("token"),
            FilterPattern::exact("secret"),
            FilterPattern::exact("key"),
        ])
    }
}

impl ParameterFilter {
    /// Creates a parameter filter from exact or regex patterns.
    #[must_use]
    pub fn new<I>(patterns: I) -> Self
    where
        I: IntoIterator<Item = FilterPattern>,
    {
        Self {
            patterns: patterns.into_iter().collect(),
        }
    }

    /// Returns a filtered copy of `params` with matching values redacted.
    #[must_use]
    pub fn filter(&self, params: &HashMap<String, Value>) -> HashMap<String, Value> {
        params
            .iter()
            .map(|(key, value)| (key.clone(), self.filter_value(key, key, value)))
            .collect()
    }

    fn filter_value(&self, path: &str, key: &str, value: &Value) -> Value {
        if self.matches(path, key) {
            return Value::String(String::from(FILTERED));
        }

        match value {
            Value::Object(map) => Value::Object(
                map.iter()
                    .map(|(child_key, child_value)| {
                        let child_path = format!("{path}.{child_key}");
                        (
                            child_key.clone(),
                            self.filter_value(&child_path, child_key, child_value),
                        )
                    })
                    .collect::<Map<String, Value>>(),
            ),
            Value::Array(values) => Value::Array(
                values
                    .iter()
                    .map(|child| self.filter_value(path, key, child))
                    .collect(),
            ),
            _ => value.clone(),
        }
    }

    fn matches(&self, path: &str, key: &str) -> bool {
        self.patterns.iter().any(|pattern| match pattern {
            FilterPattern::Exact(candidate) => {
                candidate.eq_ignore_ascii_case(path) || candidate.eq_ignore_ascii_case(key)
            }
            FilterPattern::Regex(regex) => regex.is_match(path) || regex.is_match(key),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::{FILTERED, FilterPattern, ParameterFilter};
    use serde_json::json;
    use std::collections::HashMap;

    #[test]
    fn default_filter_redacts_password_keys() {
        let filter = ParameterFilter::default();
        let params = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("name"), json!("RustRails")),
        ]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
        assert_eq!(filtered.get("name"), Some(&json!("RustRails")));
    }

    #[test]
    fn default_filter_redacts_token_keys() {
        let filter = ParameterFilter::default();
        let params = HashMap::from([(String::from("token"), json!("abc123"))]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("token"), Some(&json!(FILTERED)));
    }

    #[test]
    fn exact_patterns_match_nested_dotted_paths() {
        let filter = ParameterFilter::new([FilterPattern::exact("user.password")]);
        let params = HashMap::from([(
            String::from("user"),
            json!({"password": "secret", "name": "DHH"}),
        )]);

        let filtered = filter.filter(&params);

        assert_eq!(
            filtered.get("user"),
            Some(&json!({"password": FILTERED, "name": "DHH"}))
        );
    }

    #[test]
    fn exact_patterns_match_leaf_keys_at_any_depth() {
        let filter = ParameterFilter::new([FilterPattern::exact("password")]);
        let params = HashMap::from([(
            String::from("user"),
            json!({"password": "secret", "profile": {"password": "hidden"}}),
        )]);

        let filtered = filter.filter(&params);

        assert_eq!(
            filtered.get("user"),
            Some(&json!({"password": FILTERED, "profile": {"password": FILTERED}})),
        );
    }

    #[test]
    fn regex_patterns_can_match_variants_of_sensitive_keys() {
        let filter = ParameterFilter::new([
            FilterPattern::regex("(?i)api[_-]?key$").expect("regex should compile")
        ]);
        let params = HashMap::from([
            (String::from("api_key"), json!("abc123")),
            (String::from("api-key"), json!("def456")),
        ]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("api_key"), Some(&json!(FILTERED)));
        assert_eq!(filtered.get("api-key"), Some(&json!(FILTERED)));
    }

    #[test]
    fn regex_patterns_can_match_dotted_paths() {
        let filter =
            ParameterFilter::new([FilterPattern::regex("^credentials\\.(secret|token)$")
                .expect("regex should compile")]);
        let params = HashMap::from([(
            String::from("credentials"),
            json!({"secret": "x", "token": "y", "name": "app"}),
        )]);

        let filtered = filter.filter(&params);

        assert_eq!(
            filtered.get("credentials"),
            Some(&json!({"secret": FILTERED, "token": FILTERED, "name": "app"}))
        );
    }

    #[test]
    fn nested_arrays_are_filtered_recursively() {
        let filter = ParameterFilter::new([FilterPattern::exact("token")]);
        let params = HashMap::from([(
            String::from("users"),
            json!([
                {"token": "abc"},
                {"token": "def", "name": "Jane"}
            ]),
        )]);

        let filtered = filter.filter(&params);

        assert_eq!(
            filtered.get("users"),
            Some(&json!([
                {"token": FILTERED},
                {"token": FILTERED, "name": "Jane"}
            ])),
        );
    }

    #[test]
    fn non_matching_values_are_left_unchanged() {
        let filter = ParameterFilter::new([FilterPattern::exact("password")]);
        let params = HashMap::from([(String::from("count"), json!(3))]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("count"), Some(&json!(3)));
    }

    #[test]
    fn filtering_does_not_mutate_the_source_hash() {
        let filter = ParameterFilter::new([FilterPattern::exact("password")]);
        let params = HashMap::from([(String::from("password"), json!("secret"))]);

        let filtered = filter.filter(&params);

        assert_eq!(params.get("password"), Some(&json!("secret")));
        assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
    }

    #[test]
    fn multiple_patterns_can_redact_different_keys() {
        let filter = ParameterFilter::new([
            FilterPattern::exact("password"),
            FilterPattern::exact("token"),
        ]);
        let params = HashMap::from([
            (String::from("password"), json!("secret")),
            (String::from("token"), json!("abc123")),
        ]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("password"), Some(&json!(FILTERED)));
        assert_eq!(filtered.get("token"), Some(&json!(FILTERED)));
    }

    #[test]
    fn exact_matching_is_case_insensitive() {
        let filter = ParameterFilter::new([FilterPattern::exact("password")]);
        let params = HashMap::from([(String::from("Password"), json!("secret"))]);

        let filtered = filter.filter(&params);

        assert_eq!(filtered.get("Password"), Some(&json!(FILTERED)));
    }

    #[test]
    fn empty_hashes_round_trip_cleanly() {
        let filter = ParameterFilter::default();
        let params = HashMap::new();

        let filtered = filter.filter(&params);

        assert!(filtered.is_empty());
    }

    #[test]
    fn nested_values_can_mix_exact_and_regex_rules() {
        let filter = ParameterFilter::new([
            FilterPattern::exact("user.password"),
            FilterPattern::regex("secret$").expect("regex should compile"),
        ]);
        let params = HashMap::from([(
            String::from("user"),
            json!({
                "password": "secret",
                "profile": {"secret": "hidden", "nickname": "rails"}
            }),
        )]);

        let filtered = filter.filter(&params);

        assert_eq!(
            filtered.get("user"),
            Some(&json!({
                "password": FILTERED,
                "profile": {"secret": FILTERED, "nickname": "rails"}
            })),
        );
    }
}