claw-guard 0.1.1

Security and policy engine for ClawDB
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::error::GuardResult;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MaskType {
    Redact,
    Hash,
    Truncate { max_len: usize },
    EmailMask,
    JsonFieldMask { field_pattern: String },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MaskDirective {
    pub field_pattern: String,
    pub mask_type: MaskType,
}

#[derive(Debug, Default, Clone)]
pub struct MaskingEngine;

impl MaskingEngine {
    pub fn new() -> Self {
        Self
    }

    pub fn apply(value: &mut Value, masks: &[MaskDirective]) -> GuardResult<()> {
        Self::apply_at_path(value, "$", masks)
    }

    fn apply_at_path(value: &mut Value, path: &str, masks: &[MaskDirective]) -> GuardResult<()> {
        for directive in masks {
            if glob_matches(&directive.field_pattern, path) {
                apply_mask_type(value, &directive.mask_type)?;
            }
        }

        match value {
            Value::Object(map) => {
                for (key, child) in map.iter_mut() {
                    let child_path = format!("{path}.{key}");
                    Self::apply_at_path(child, &child_path, masks)?;
                }
            }
            Value::Array(items) => {
                for (index, child) in items.iter_mut().enumerate() {
                    let child_path = format!("{path}[{index}]");
                    Self::apply_at_path(child, &child_path, masks)?;
                }
            }
            _ => {}
        }

        Ok(())
    }
}

fn apply_mask_type(value: &mut Value, mask_type: &MaskType) -> GuardResult<()> {
    match mask_type {
        MaskType::Redact => {
            *value = Value::String("[REDACTED]".to_owned());
        }
        MaskType::Hash => {
            let serialized = match value {
                Value::String(string) => string.clone(),
                _ => serde_json::to_string(value)?,
            };
            *value = Value::String(blake3::hash(serialized.as_bytes()).to_hex().to_string());
        }
        MaskType::Truncate { max_len } => {
            if let Some(string) = value.as_str() {
                let truncated = string.chars().take(*max_len).collect::<String>();
                *value = Value::String(truncated);
            }
        }
        MaskType::EmailMask => {
            if let Some(string) = value.as_str() {
                *value = Value::String(mask_email(string));
            }
        }
        MaskType::JsonFieldMask { field_pattern } => {
            let nested_masks = vec![MaskDirective {
                field_pattern: format!("$.{field_pattern}"),
                mask_type: MaskType::Redact,
            }];
            MaskingEngine::apply(value, &nested_masks)?;
        }
    }
    Ok(())
}

fn mask_email(email: &str) -> String {
    let Some((local, domain)) = email.split_once('@') else {
        return "[REDACTED]".to_owned();
    };
    let prefix = local.chars().next().unwrap_or('*');
    format!("{prefix}***@{domain}")
}

fn glob_matches(pattern: &str, value: &str) -> bool {
    if pattern == "*" || pattern == value {
        return true;
    }

    let mut remaining = value;
    let parts = pattern.split('*').collect::<Vec<_>>();
    if !pattern.starts_with('*') {
        let first = parts.first().copied().unwrap_or_default();
        if !remaining.starts_with(first) {
            return false;
        }
        remaining = &remaining[first.len()..];
    }

    for part in parts.iter().filter(|segment| !segment.is_empty()) {
        let Some(position) = remaining.find(part) else {
            return false;
        };
        remaining = &remaining[position + part.len()..];
    }

    pattern.ends_with('*') || remaining.is_empty()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn applies_all_mask_types() {
        let mut payload = json!({
            "secret": "open",
            "email": "alice@example.com",
            "note": "truncate-me",
            "nested": {"token": "alpha"},
            "hash_me": "value"
        });
        let masks = vec![
            MaskDirective {
                field_pattern: "$.secret".to_owned(),
                mask_type: MaskType::Redact,
            },
            MaskDirective {
                field_pattern: "$.email".to_owned(),
                mask_type: MaskType::EmailMask,
            },
            MaskDirective {
                field_pattern: "$.note".to_owned(),
                mask_type: MaskType::Truncate { max_len: 4 },
            },
            MaskDirective {
                field_pattern: "$.nested".to_owned(),
                mask_type: MaskType::JsonFieldMask {
                    field_pattern: "token".to_owned(),
                },
            },
            MaskDirective {
                field_pattern: "$.hash_me".to_owned(),
                mask_type: MaskType::Hash,
            },
        ];

        MaskingEngine::apply(&mut payload, &masks).expect("masking should succeed");

        assert_eq!(payload["secret"], "[REDACTED]");
        assert_eq!(payload["email"], "a***@example.com");
        assert_eq!(payload["note"], "trun");
        assert_eq!(payload["nested"]["token"], "[REDACTED]");
        assert_ne!(payload["hash_me"], "value");
    }
}