use super::config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
use super::hash::Hasher;
use crate::error::FaucetError;
use regex::Regex;
use std::collections::HashSet;
fn config_err(msg: impl Into<String>) -> FaucetError {
FaucetError::Config(format!("masking: {}", msg.into()))
}
#[derive(Debug, Clone)]
pub struct CompiledMasking {
pub(crate) rules: Vec<CompiledRule>,
pub(crate) hasher: Hasher,
}
#[derive(Debug, Clone)]
pub(crate) struct CompiledRule {
pub(crate) label: String,
pub(crate) field_pattern: Option<Regex>,
pub(crate) value_detector: Option<Detector>,
pub(crate) fields: HashSet<String>,
pub(crate) action: MaskAction,
pub(crate) action_label: &'static str,
}
impl CompiledMasking {
pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
Self::compile_scoped(spec, None)
}
pub fn compile_for_sink(spec: &MaskingSpec, sink_ids: &[&str]) -> Result<Self, FaucetError> {
Self::compile_scoped(spec, Some(sink_ids))
}
fn compile_scoped(spec: &MaskingSpec, sink_ids: Option<&[&str]>) -> Result<Self, FaucetError> {
if spec.rules.is_empty() {
return Err(config_err("at least one rule is required"));
}
let mut rules = Vec::with_capacity(spec.rules.len());
for (i, rule) in spec.rules.iter().enumerate() {
let compiled = compile_rule(rule, i)?; if applies(rule, sink_ids) {
rules.push(compiled);
}
}
Ok(Self {
rules,
hasher: Hasher::from_key(spec.key.as_deref()),
})
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
}
fn applies(rule: &MaskRule, sink_ids: Option<&[&str]>) -> bool {
match sink_ids {
None => true,
Some(ids) => {
rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| ids.contains(&t.as_str()))
}
}
}
fn compile_rule(rule: &MaskRule, index: usize) -> Result<CompiledRule, FaucetError> {
let MatchSpec {
field_pattern,
value_detector,
fields,
} = &rule.matcher;
if field_pattern.is_none() && value_detector.is_none() && fields.is_empty() {
return Err(config_err(format!(
"rule {index}: `match` must set at least one of field_pattern / value_detector / fields"
)));
}
let field_pattern = match field_pattern {
Some(p) => Some(Regex::new(p).map_err(|e| {
config_err(format!(
"rule {index}: invalid field_pattern regex '{p}': {e}"
))
})?),
None => None,
};
if let MaskAction::Tokenize { prefix: Some(p) } = &rule.action
&& p.is_empty()
{
return Err(config_err(format!(
"rule {index}: tokenize prefix, when set, must be non-empty (omit it for no prefix)"
)));
}
let label = rule.name.clone().unwrap_or_else(|| format!("rule_{index}"));
Ok(CompiledRule {
label,
field_pattern,
value_detector: *value_detector,
fields: fields.iter().cloned().collect(),
action: rule.action.clone(),
action_label: rule.action.label(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec(v: serde_json::Value) -> MaskingSpec {
serde_json::from_value(v).unwrap()
}
#[test]
fn compiles_valid_policy() {
let c = CompiledMasking::compile(&spec(json!({
"rules": [
{ "match": { "field_pattern": "email" }, "action": { "type": "redact" } },
{ "match": { "value_detector": "ssn" }, "action": { "type": "hash" } }
]
})))
.unwrap();
assert_eq!(c.rule_count(), 2);
assert!(!c.is_empty());
assert_eq!(c.rules[0].label, "rule_0");
assert_eq!(c.rules[0].action_label, "redact");
}
#[test]
fn empty_rules_rejected() {
let err = CompiledMasking::compile(&spec(json!({ "rules": [] }))).unwrap_err();
assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one rule")));
}
#[test]
fn empty_match_rejected() {
let err = CompiledMasking::compile(&spec(json!({
"rules": [{ "match": {}, "action": { "type": "redact" } }]
})))
.unwrap_err();
assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one of")));
}
#[test]
fn bad_regex_rejected() {
let err = CompiledMasking::compile(&spec(json!({
"rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" } }]
})))
.unwrap_err();
assert!(matches!(err, FaucetError::Config(m) if m.contains("invalid field_pattern")));
}
#[test]
fn empty_tokenize_prefix_rejected() {
let err = CompiledMasking::compile(&spec(json!({
"rules": [{ "match": { "fields": ["t"] }, "action": { "type": "tokenize", "prefix": "" } }]
})))
.unwrap_err();
assert!(matches!(err, FaucetError::Config(m) if m.contains("tokenize prefix")));
}
#[test]
fn custom_rule_name_used_as_label() {
let c = CompiledMasking::compile(&spec(json!({
"rules": [{ "name": "cards", "match": { "value_detector": "credit_card" },
"action": { "type": "partial" } }]
})))
.unwrap();
assert_eq!(c.rules[0].label, "cards");
}
#[test]
fn sink_scoping_filters_rules_but_validates_all() {
let s = spec(json!({
"rules": [
{ "match": { "fields": ["a"] }, "action": { "type": "redact" }, "applies_to": ["secure"] },
{ "match": { "fields": ["b"] }, "action": { "type": "redact" }, "applies_to": ["default"] },
{ "match": { "fields": ["c"] }, "action": { "type": "redact" } }
]
}));
let def = CompiledMasking::compile_for_sink(&s, &["default"]).unwrap();
assert_eq!(def.rule_count(), 2);
let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
assert_eq!(sec.rule_count(), 2); let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
assert_eq!(other.rule_count(), 1); assert!(!other.is_empty());
let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
assert_eq!(by_kind.rule_count(), 2); }
#[test]
fn sink_scoping_still_validates_inapplicable_rule_regex() {
let s = spec(json!({
"rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" },
"applies_to": ["secure"] }]
}));
assert!(CompiledMasking::compile_for_sink(&s, &["default"]).is_err());
}
}