claw-guard 0.1.0

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");
	}
}