use super::config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
use super::hash::Hasher;
use crate::error::FaucetError;
use regex::Regex;
use std::collections::HashSet;
pub(crate) fn warn_phone_narrowed() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
"masking/policy: `value_detector: phone` matches only E.164 (`+…`) and separated \
North-American numbers; bare digit runs and national formats such as \
`020 7946 0958` pass through. Use `phone_loose` for the broader matcher"
);
});
}
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,
pub(crate) needs_paths: bool,
pub(crate) stringify_numbers: bool,
}
impl CompiledMasking {
#[cfg_attr(not(feature = "arrow"), allow(dead_code))]
pub(crate) fn rewrites_name(&self, name: &str) -> bool {
self.rules.iter().any(|r| {
r.fields.contains(name) || r.field_pattern.as_ref().is_some_and(|p| p.is_match(name))
})
}
#[cfg_attr(not(feature = "arrow"), allow(dead_code))]
pub(crate) fn retypes_by_value(&self) -> bool {
self.stringify_numbers
}
}
#[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);
}
}
if rules
.iter()
.any(|r| r.value_detector == Some(Detector::Phone))
{
warn_phone_narrowed();
}
let needs_paths = rules
.iter()
.any(|r| r.field_pattern.is_some() || !r.fields.is_empty());
let stringify_numbers = rules
.iter()
.any(|r| r.value_detector.is_some_and(super::detect::matches_numbers));
Ok(Self {
rules,
hasher: Hasher::from_key(spec.key.as_deref()),
needs_paths,
stringify_numbers,
})
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn name_action_for(&self, column: &str) -> Option<&'static str> {
let mut end = 0;
while end < column.len() {
end = column[end..].find('.').map_or(column.len(), |i| end + i);
let prefix = &column[..end];
let leaf = end == column.len();
for r in &self.rules {
if r.fields.contains(prefix)
|| r.field_pattern
.as_ref()
.is_some_and(|re| re.is_match(prefix))
{
return Some(r.action_label);
}
if leaf && r.value_detector.is_some() {
return None;
}
}
end += 1;
}
None
}
}
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;
#[test]
fn retypes_by_value_only_for_number_matching_detectors() {
let with = |det: &str| {
let s = spec(json!({"rules": [
{"match": {"fields": ["id"]}, "action": {"type": "hash"}},
{"match": {"value_detector": det}, "action": {"type": "hash"}}
]}));
CompiledMasking::compile(&s).unwrap().retypes_by_value()
};
assert!(with("credit_card"));
assert!(with("phone_loose"));
for det in ["email", "ssn", "phone", "ipv4"] {
assert!(!with(det), "{det}");
}
let name_only = spec(json!({"rules": [
{"match": {"field_pattern": "card"}, "action": {"type": "hash"}}
]}));
assert!(
!CompiledMasking::compile(&name_only)
.unwrap()
.retypes_by_value()
);
}
#[test]
fn a_phone_rule_compiles_and_warns_once() {
let s = spec(
json!({"rules": [{"match": {"value_detector": "phone"}, "action": {"type": "hash"}}]}),
);
assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
}
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 name_action_for_is_unknown_behind_an_earlier_detector_rule() {
let c = CompiledMasking::compile(&spec(json!({
"rules": [
{ "match": { "value_detector": "email" }, "action": { "type": "redact" } },
{ "match": { "fields": ["contact"] }, "action": { "type": "hash" } },
{ "match": { "fields": ["mail"] }, "action": { "type": "hash" } }
]
})))
.unwrap();
assert_eq!(c.name_action_for("mail"), None);
assert_eq!(c.name_action_for("contact.mail"), Some("hash"));
}
#[test]
fn name_action_for_matches_the_field_or_an_ancestor() {
let c = CompiledMasking::compile_for_sink(
&spec(json!({
"rules": [
{ "match": { "fields": ["phones"] }, "action": { "type": "hash" } },
{ "match": { "field_pattern": "^card$" }, "action": { "type": "partial" } },
{ "match": { "fields": ["user.ssn"] }, "action": { "type": "tokenize" } },
{ "match": { "fields": ["other"] }, "action": { "type": "redact" },
"applies_to": ["elsewhere"] },
{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }
]
})),
&["here"],
)
.unwrap();
assert_eq!(c.name_action_for("phones"), Some("hash"));
assert_eq!(c.name_action_for("phones.0"), Some("hash"));
assert_eq!(c.name_action_for("card.number"), Some("partial"));
assert_eq!(c.name_action_for("card"), Some("partial"));
assert_eq!(c.name_action_for("user.ssn"), Some("tokenize"));
assert_eq!(
c.name_action_for("ssn"),
None,
"fields match the full path only"
);
assert_eq!(c.name_action_for("user"), None);
assert_eq!(c.name_action_for("cardholder"), None);
assert_eq!(c.name_action_for("other"), None, "scoped to another sink");
assert_eq!(
c.name_action_for("email"),
None,
"detectors are value-based"
);
assert_eq!(c.name_action_for(""), None);
}
#[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());
}
}