Skip to main content

claw_guard/masking/
mod.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::error::GuardResult;
5
6/// Masking strategy applied to a matched field.
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub enum MaskType {
9    /// Replaces the value with `[REDACTED]`.
10    Redact,
11    /// Replaces the value with a BLAKE3 hex digest.
12    HashBlake3,
13    /// Keeps the first `n` characters.
14    Truncate { n: usize },
15    /// Keeps the first character of the local part and preserves the domain.
16    EmailMask,
17    /// Redacts matching keys inside a JSON object.
18    JsonFieldMask { pattern: String },
19}
20
21/// A field mask directive.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct MaskDirective {
24    /// Path or glob-like field pattern.
25    pub field_pattern: String,
26    /// Mask type to apply.
27    pub mask_type: MaskType,
28}
29
30/// Applies configured masking directives to JSON values.
31#[derive(Debug, Clone, Default)]
32pub struct MaskingEngine;
33
34impl MaskingEngine {
35    /// Creates a new masking engine.
36    pub fn new() -> Self {
37        Self
38    }
39
40    /// Applies mask directives to a JSON value in place.
41    pub fn apply(value: &mut Value, masks: &[MaskDirective]) -> GuardResult<()> {
42        apply_recursive(value, "$", masks)
43    }
44}
45
46fn apply_recursive(value: &mut Value, path: &str, masks: &[MaskDirective]) -> GuardResult<()> {
47    for directive in masks {
48        if matches_pattern(&directive.field_pattern, path) {
49            apply_mask(value, &directive.mask_type)?;
50        }
51    }
52
53    match value {
54        Value::Object(map) => {
55            for (key, child) in map.iter_mut() {
56                let child_path = format!("{path}.{key}");
57                apply_recursive(child, &child_path, masks)?;
58            }
59        }
60        Value::Array(items) => {
61            for (index, child) in items.iter_mut().enumerate() {
62                let child_path = format!("{path}[{index}]");
63                apply_recursive(child, &child_path, masks)?;
64            }
65        }
66        _ => {}
67    }
68
69    Ok(())
70}
71
72fn apply_mask(value: &mut Value, mask_type: &MaskType) -> GuardResult<()> {
73    match mask_type {
74        MaskType::Redact => *value = Value::String("[REDACTED]".to_owned()),
75        MaskType::HashBlake3 => {
76            let raw = match value {
77                Value::String(inner) => inner.clone(),
78                _ => serde_json::to_string(value)?,
79            };
80            *value = Value::String(blake3::hash(raw.as_bytes()).to_hex().to_string());
81        }
82        MaskType::Truncate { n } => {
83            if let Some(inner) = value.as_str() {
84                *value = Value::String(inner.chars().take(*n).collect());
85            }
86        }
87        MaskType::EmailMask => {
88            if let Some(inner) = value.as_str() {
89                *value = Value::String(mask_email(inner));
90            }
91        }
92        MaskType::JsonFieldMask { pattern } => {
93            redact_nested_fields(value, pattern);
94        }
95    }
96
97    Ok(())
98}
99
100fn redact_nested_fields(value: &mut Value, pattern: &str) {
101    match value {
102        Value::Object(map) => {
103            for (key, child) in map.iter_mut() {
104                if matches_pattern(pattern, key) {
105                    *child = Value::String("[REDACTED]".to_owned());
106                } else {
107                    redact_nested_fields(child, pattern);
108                }
109            }
110        }
111        Value::Array(items) => {
112            for item in items {
113                redact_nested_fields(item, pattern);
114            }
115        }
116        _ => {}
117    }
118}
119
120fn mask_email(value: &str) -> String {
121    match value.split_once('@') {
122        Some((local, domain)) => {
123            let first = local.chars().next().unwrap_or('*');
124            format!("{first}***@{domain}")
125        }
126        None => "[REDACTED]".to_owned(),
127    }
128}
129
130fn matches_pattern(pattern: &str, value: &str) -> bool {
131    if pattern == "*" || pattern == value {
132        return true;
133    }
134
135    let mut remainder = value;
136    let parts = pattern.split('*').filter(|part| !part.is_empty());
137    let anchored_start = !pattern.starts_with('*');
138    let anchored_end = !pattern.ends_with('*');
139    let mut first = true;
140
141    for part in parts {
142        if first && anchored_start {
143            if !remainder.starts_with(part) {
144                return false;
145            }
146            remainder = &remainder[part.len()..];
147        } else if let Some(index) = remainder.find(part) {
148            remainder = &remainder[index + part.len()..];
149        } else {
150            return false;
151        }
152        first = false;
153    }
154
155    !anchored_end || remainder.is_empty()
156}