use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use crate::expressions::condition::{self, ConditionExpr};
use crate::types::AttributeValue;
#[derive(Debug, Deserialize)]
pub struct ImportConfig {
#[serde(default)]
pub rules: Vec<RuleConfig>,
#[serde(default)]
pub consistency: Option<ConsistencyConfig>,
}
#[derive(Debug, Deserialize)]
pub struct RuleConfig {
#[serde(rename = "match")]
pub match_expr: String,
#[serde(default)]
pub names: HashMap<String, String>,
#[serde(default)]
pub values: HashMap<String, toml::Value>,
pub path: String,
#[serde(default)]
pub tables: Vec<String>,
pub action: ActionConfig,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ActionConfig {
Fake {
generator: String,
seed_env: Option<String>,
},
Mask {
#[serde(default = "default_keep_last")]
keep_last: usize,
#[serde(default = "default_mask_char")]
mask_char: String,
},
Hash {
salt_env: Option<String>,
},
Redact,
Null,
}
fn default_keep_last() -> usize {
4
}
fn default_mask_char() -> String {
"*".to_string()
}
#[derive(Debug, Deserialize)]
pub struct ConsistencyConfig {
pub fields: Vec<String>,
}
#[derive(Debug)]
pub struct ValidatedRule {
pub condition: ConditionExpr,
pub names: Option<HashMap<String, String>>,
pub values: Option<HashMap<String, AttributeValue>>,
pub path: Vec<crate::expressions::PathElement>,
pub action: ValidatedAction,
pub tables: Option<Vec<String>>,
}
impl ValidatedRule {
pub fn applies_to(&self, table: &str) -> bool {
self.tables
.as_ref()
.is_none_or(|tables| tables.iter().any(|name| name == table))
}
}
#[derive(Clone)]
pub struct Salt(zeroize::Zeroizing<Vec<u8>>);
impl Salt {
pub fn new(bytes: Vec<u8>) -> Self {
Self(zeroize::Zeroizing::new(bytes))
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for Salt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Salt([REDACTED])")
}
}
#[derive(Debug, Clone)]
pub enum ValidatedAction {
Fake {
generator: String,
seed: Option<Salt>,
},
Mask {
keep_last: usize,
mask_char: char,
},
Hash {
salt: Salt,
},
Redact,
Null,
}
pub fn load_and_validate(
path: &Path,
) -> Result<(Vec<ValidatedRule>, Option<ConsistencyConfig>), String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read config file {}: {e}", path.display()))?;
let config: ImportConfig =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML config: {e}"))?;
let mut validated = Vec::with_capacity(config.rules.len());
for (i, rule) in config.rules.iter().enumerate() {
let condition = condition::parse(&rule.match_expr).map_err(|e| {
format!(
"Rule {}: invalid match expression '{}': {e}",
i + 1,
rule.match_expr
)
})?;
let names = convert_names(&rule.names, i + 1)?;
let values = convert_values(&rule.values, i + 1)?;
validate_name_refs(&condition, &names, i + 1)?;
validate_value_refs(&condition, &values, i + 1)?;
condition::validate_static(&condition, &values)
.and_then(|()| condition::validate_operand_semantics(&condition, &names, &values))
.map_err(|e| {
format!(
"Rule {}: invalid match expression '{}': {e}",
i + 1,
rule.match_expr
)
})?;
let path = parse_path(&rule.path)
.map_err(|e| format!("Rule {}: invalid path '{}': {e}", i + 1, rule.path))?;
let action = validate_action(&rule.action, i + 1)?;
let tables = scoped_tables(&rule.tables, i + 1)?;
validated.push(ValidatedRule {
condition,
names,
values,
path,
action,
tables,
});
}
Ok((validated, config.consistency))
}
fn scoped_tables(tables: &[String], rule_num: usize) -> Result<Option<Vec<String>>, String> {
if tables.is_empty() {
return Ok(None);
}
if let Some(blank) = tables.iter().find(|t| t.trim().is_empty()) {
return Err(format!(
"Rule {rule_num}: tables contains an empty name ({blank:?}); name the table or \
leave the list out to apply the rule everywhere"
));
}
Ok(Some(tables.to_vec()))
}
fn convert_names(
names: &HashMap<String, String>,
rule_num: usize,
) -> Result<Option<HashMap<String, String>>, String> {
if names.is_empty() {
return Ok(None);
}
for alias in names.keys() {
if !alias.starts_with('#') {
return Err(format!(
"Rule {rule_num}: name alias '{alias}' must start with '#' (for example \"#n\")"
));
}
}
Ok(Some(names.clone()))
}
fn validate_name_refs(
condition: &ConditionExpr,
names: &Option<HashMap<String, String>>,
rule_num: usize,
) -> Result<(), String> {
condition::validate_name_refs(condition, names)
.map_err(|e| format!("Rule {rule_num}: {e}. Add it to the rule's names table"))?;
if let Some(names) = names {
let used = crate::expressions::condition::extract_name_refs(condition);
let mut unused: Vec<&String> = names.keys().filter(|k| !used.contains(k)).collect();
unused.sort();
if let Some(alias) = unused.first() {
return Err(format!(
"Rule {rule_num}: name {alias} is not referenced by the match expression"
));
}
}
Ok(())
}
fn convert_values(
values: &HashMap<String, toml::Value>,
rule_num: usize,
) -> Result<Option<HashMap<String, AttributeValue>>, String> {
if values.is_empty() {
return Ok(None);
}
let mut converted = HashMap::with_capacity(values.len());
for (name, value) in values {
if !name.starts_with(':') {
return Err(format!(
"Rule {rule_num}: value name '{name}' must start with ':' (for example \":prefix\")"
));
}
let attr = match value {
toml::Value::String(s) => AttributeValue::S(s.clone()),
toml::Value::Integer(i) => AttributeValue::N(i.to_string()),
toml::Value::Float(f) if f.is_finite() => AttributeValue::N(f.to_string()),
toml::Value::Float(_) => {
return Err(format!(
"Rule {rule_num}: value '{name}' must be a finite number"
));
}
toml::Value::Boolean(b) => AttributeValue::BOOL(*b),
_ => {
return Err(format!(
"Rule {rule_num}: value '{name}' must be a string, number or boolean"
));
}
};
converted.insert(name.clone(), attr);
}
Ok(Some(converted))
}
fn validate_value_refs(
condition: &ConditionExpr,
values: &Option<HashMap<String, AttributeValue>>,
rule_num: usize,
) -> Result<(), String> {
let refs = condition::extract_value_refs(condition);
for name in &refs {
let defined = values.as_ref().is_some_and(|v| v.contains_key(name));
if !defined {
return Err(format!(
"Rule {rule_num}: match expression references {name} but values does not define it. \
Add values = {{ \"{name}\" = \"...\" }} to the rule"
));
}
}
if let Some(values) = values {
let mut unused: Vec<&String> = values.keys().filter(|k| !refs.contains(k)).collect();
unused.sort();
if let Some(name) = unused.first() {
return Err(format!(
"Rule {rule_num}: value {name} is not referenced by the match expression"
));
}
}
Ok(())
}
pub(super) fn parse_path(path: &str) -> Result<Vec<crate::expressions::PathElement>, String> {
use crate::expressions::PathElement;
if path.is_empty() {
return Err("empty path".to_string());
}
let mut elements = Vec::new();
for part in path.split('.') {
if part.is_empty() {
return Err("empty path segment".to_string());
}
if part.starts_with('[') {
return Err(format!(
"segment '{part}' starts with an index; an index has to follow an attribute \
name, as in items[0]"
));
}
if part.starts_with('#') {
return Err(format!(
"'{part}' is an expression attribute name; a path names the attribute itself, \
so write the attribute's own name here"
));
}
if let Some(bracket_pos) = part.find('[') {
let name = &part[..bracket_pos];
if !name.is_empty() {
elements.push(PathElement::Attribute(name.to_string()));
}
let rest = &part[bracket_pos..];
let mut remaining = rest;
while remaining.starts_with('[') {
let end = remaining
.find(']')
.ok_or_else(|| format!("unclosed bracket in path: {path}"))?;
let idx: usize = remaining[1..end]
.parse()
.map_err(|_| format!("invalid array index in path: {path}"))?;
elements.push(PathElement::Index(idx));
remaining = &remaining[end + 1..];
}
if !remaining.is_empty() {
return Err(format!(
"unexpected '{remaining}' after the index in '{part}'; a nested attribute \
needs a dot, as in {}.{remaining}",
&part[..part.len() - remaining.len()]
));
}
} else {
elements.push(PathElement::Attribute(part.to_string()));
}
}
Ok(elements)
}
const VALID_GENERATORS: &[&str] = &[
"safe_email",
"name",
"phone_number",
"address",
"company_name",
"sentence",
"word",
"first_name",
"last_name",
];
const MIN_SECRET_LEN: usize = 16;
fn required_secret(
rule_num: usize,
env_var: &str,
purpose: &str,
why_it_must_be_secret: &str,
) -> Result<Vec<u8>, String> {
let value = std::env::var(env_var).map_err(|e| match e {
std::env::VarError::NotPresent => format!(
"Rule {rule_num}: environment variable '{env_var}' not set (required for {purpose})"
),
std::env::VarError::NotUnicode(_) => format!(
"Rule {rule_num}: environment variable '{env_var}' is set but is not valid UTF-8 \
(required for {purpose}). Generate one with `openssl rand -base64 24`"
),
})?;
if value.is_empty() {
return Err(format!(
"Rule {rule_num}: environment variable '{env_var}' is empty. \
{why_it_must_be_secret}, so set it to a secret value"
));
}
if value.len() < MIN_SECRET_LEN {
return Err(format!(
"Rule {rule_num}: environment variable '{env_var}' is {} bytes, and at least \
{MIN_SECRET_LEN} are required for {purpose}. {why_it_must_be_secret}, and a \
short value is guessable in the time it takes to read this. Generate one with \
`openssl rand -base64 24`",
value.len()
));
}
Ok(value.into_bytes())
}
fn validate_action(action: &ActionConfig, rule_num: usize) -> Result<ValidatedAction, String> {
match action {
ActionConfig::Fake {
generator,
seed_env,
} => {
if !VALID_GENERATORS.contains(&generator.as_str()) {
return Err(format!(
"Rule {rule_num}: unknown generator '{}'. Valid generators: {}",
generator,
VALID_GENERATORS.join(", ")
));
}
let seed = match seed_env {
Some(env_var) => Some(Salt::new(required_secret(
rule_num,
env_var,
"the fake seed",
"The seed is what stops anyone with the original data reproducing \
the anonymised values",
)?)),
None => None,
};
Ok(ValidatedAction::Fake {
generator: generator.clone(),
seed,
})
}
ActionConfig::Mask {
keep_last,
mask_char,
} => {
let ch = mask_char
.chars()
.next()
.ok_or_else(|| format!("Rule {rule_num}: mask_char must not be empty"))?;
if mask_char.chars().count() > 1 {
return Err(format!(
"Rule {rule_num}: mask_char must be a single character"
));
}
Ok(ValidatedAction::Mask {
keep_last: *keep_last,
mask_char: ch,
})
}
ActionConfig::Hash { salt_env } => {
let salt = match salt_env {
Some(env_var) => required_secret(
rule_num,
env_var,
"hash salt",
"An unkeyed digest of a low-entropy value is trivially reversible via rainbow tables",
)?,
None => {
return Err(format!(
"Rule {rule_num}: salt_env is required for hash actions. \
An unkeyed digest of a low-entropy value is trivially reversible via \
rainbow tables. Set salt_env to an environment variable containing a \
secret salt value."
));
}
};
Ok(ValidatedAction::Hash {
salt: Salt::new(salt),
})
}
ActionConfig::Redact => Ok(ValidatedAction::Redact),
ActionConfig::Null => Ok(ValidatedAction::Null),
}
}
pub fn matches_item(rule: &ValidatedRule, item: &HashMap<String, AttributeValue>) -> bool {
crate::expressions::evaluate_without_tracking(&rule.condition, item, &rule.names, &rule.values)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_path() {
let path = parse_path("email").unwrap();
assert_eq!(path.len(), 1);
assert_eq!(
path[0],
crate::expressions::PathElement::Attribute("email".to_string())
);
}
#[test]
fn test_parse_nested_path() {
let path = parse_path("address.city").unwrap();
assert_eq!(path.len(), 2);
assert_eq!(
path[0],
crate::expressions::PathElement::Attribute("address".to_string())
);
assert_eq!(
path[1],
crate::expressions::PathElement::Attribute("city".to_string())
);
}
#[test]
fn test_parse_indexed_path() {
let path = parse_path("items[0].name").unwrap();
assert_eq!(path.len(), 3);
assert_eq!(
path[0],
crate::expressions::PathElement::Attribute("items".to_string())
);
assert_eq!(path[1], crate::expressions::PathElement::Index(0));
assert_eq!(
path[2],
crate::expressions::PathElement::Attribute("name".to_string())
);
}
#[test]
fn test_empty_path_error() {
assert!(parse_path("").is_err());
}
#[test]
fn test_validate_fake_action() {
let action = ActionConfig::Fake {
generator: "safe_email".to_string(),
seed_env: None,
};
assert!(validate_action(&action, 1).is_ok());
}
#[test]
fn test_validate_fake_unknown_generator() {
let action = ActionConfig::Fake {
generator: "unknown".to_string(),
seed_env: None,
};
assert!(validate_action(&action, 1).is_err());
}
#[test]
fn test_seed_env_is_resolved_and_required_to_be_non_empty() {
unsafe { std::env::set_var("DYNOXIDE_TEST_SEED", "a-seed-long-enough") };
let action = ActionConfig::Fake {
generator: "safe_email".to_string(),
seed_env: Some("DYNOXIDE_TEST_SEED".to_string()),
};
match validate_action(&action, 1).unwrap() {
ValidatedAction::Fake { seed, .. } => assert_eq!(
seed.expect("seed should be resolved").as_bytes(),
b"a-seed-long-enough",
"the configured seed must reach the action"
),
other => panic!("expected Fake, got {other:?}"),
}
unsafe { std::env::set_var("DYNOXIDE_TEST_SEED", "") };
let err = validate_action(&action, 1).unwrap_err();
assert!(err.contains("is empty"), "{err}");
unsafe { std::env::remove_var("DYNOXIDE_TEST_SEED") };
let err = validate_action(&action, 1).unwrap_err();
assert!(err.contains("not set"), "{err}");
}
#[test]
fn test_fake_without_seed_env_has_no_seed() {
let action = ActionConfig::Fake {
generator: "safe_email".to_string(),
seed_env: None,
};
match validate_action(&action, 1).unwrap() {
ValidatedAction::Fake { seed, .. } => assert!(seed.is_none()),
other => panic!("expected Fake, got {other:?}"),
}
}
#[test]
fn test_seed_env_parses_from_a_rules_file() {
let config: ImportConfig = toml::from_str(
r#"
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "fake", generator = "safe_email", seed_env = "SOME_SEED" }
"#,
)
.unwrap();
match &config.rules[0].action {
ActionConfig::Fake { seed_env, .. } => {
assert_eq!(seed_env.as_deref(), Some("SOME_SEED"))
}
other => panic!("expected Fake, got {other:?}"),
}
}
#[test]
fn test_validate_mask_action() {
let action = ActionConfig::Mask {
keep_last: 4,
mask_char: "*".to_string(),
};
let result = validate_action(&action, 1).unwrap();
match result {
ValidatedAction::Mask {
keep_last,
mask_char,
} => {
assert_eq!(keep_last, 4);
assert_eq!(mask_char, '*');
}
_ => panic!("expected Mask"),
}
}
#[test]
fn test_validate_hash_no_salt_rejected() {
let action = ActionConfig::Hash { salt_env: None };
let err = validate_action(&action, 1).unwrap_err();
assert!(err.contains("salt_env is required"));
}
#[test]
fn test_salt_redacted_in_debug_output() {
let salt = Salt::new(b"super-secret-value".to_vec());
let debug_str = format!("{:?}", salt);
assert_eq!(debug_str, "Salt([REDACTED])");
assert!(!debug_str.contains("super"));
assert!(!debug_str.contains("secret"));
let action = ValidatedAction::Hash { salt };
let action_debug = format!("{:?}", action);
assert!(action_debug.contains("[REDACTED]"));
assert!(!action_debug.contains("super"));
let seeded = ValidatedAction::Fake {
generator: "safe_email".to_string(),
seed: Some(Salt::new(b"super-secret-value".to_vec())),
};
let seeded_debug = format!("{:?}", seeded);
assert!(seeded_debug.contains("[REDACTED]"));
assert!(!seeded_debug.contains("super"));
let rule = ValidatedRule {
tables: None,
condition: crate::expressions::condition::parse("attribute_exists(email)").unwrap(),
names: None,
values: None,
path: vec![crate::expressions::PathElement::Attribute(
"email".to_string(),
)],
action,
};
let rule_debug = format!("{:?}", rule);
assert!(rule_debug.contains("[REDACTED]"));
assert!(!rule_debug.contains("super"));
}
#[test]
fn a_rule_can_be_scoped_to_tables() {
let rule = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "redact" }
tables = ["Orders", "Customers"]
"#,
)
.unwrap();
assert_eq!(
rule.tables.as_deref(),
Some(&["Orders".to_string(), "Customers".to_string()][..])
);
let everywhere = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "redact" }
"#,
)
.unwrap();
assert_eq!(everywhere.tables, None, "no list means every table");
let err = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "redact" }
tables = ["Orders", " "]
"#,
)
.unwrap_err();
assert!(err.contains("empty name"), "{err}");
}
#[test]
fn a_path_is_read_in_full_or_refused() {
for bad in ["a[0]b", "items[0]extra", "a[0]b[1]", "a[0][1]junk"] {
let err = parse_path(bad).unwrap_err();
assert!(err.contains("after the index"), "{bad}: {err}");
}
for bad in ["[0]", "[0].a", "a.[1]"] {
let err = parse_path(bad).unwrap_err();
assert!(err.contains("starts with an index"), "{bad}: {err}");
}
assert_eq!(parse_path("a[0].b[1]").unwrap().len(), 4);
assert_eq!(parse_path("items[0][1]").unwrap().len(), 3);
assert_eq!(parse_path("address.city").unwrap().len(), 2);
}
#[cfg(unix)]
#[test]
fn a_secret_that_is_not_utf8_is_not_reported_as_unset() {
use std::os::unix::ffi::OsStrExt;
unsafe {
std::env::set_var(
"DYNOXIDE_TEST_BAD_UTF8",
std::ffi::OsStr::from_bytes(b"\xff\xfe0123456789abcdef"),
)
};
let err = required_secret(1, "DYNOXIDE_TEST_BAD_UTF8", "a test", "because").unwrap_err();
unsafe { std::env::remove_var("DYNOXIDE_TEST_BAD_UTF8") };
assert!(err.contains("not valid UTF-8"), "{err}");
assert!(!err.contains("not set"), "{err}");
assert!(
!err.contains("0123456789"),
"the bytes stay out of the message: {err}"
);
}
#[test]
fn a_name_the_match_expression_never_uses_is_rejected() {
let err = parsed_rule(
r##"
[[rules]]
match = "attribute_exists(#a)"
path = "email"
action = { type = "redact" }
names = { "#a" = "email", "#b" = "phone" }
"##,
)
.unwrap_err();
assert!(
err.contains("#b") && err.contains("is not referenced"),
"{err}"
);
}
#[test]
fn a_value_the_match_expression_never_uses_is_rejected() {
let err = parsed_rule(
r##"
[[rules]]
match = "email = :a"
path = "email"
action = { type = "redact" }
values = { ":a" = "x", ":b" = "y" }
"##,
)
.unwrap_err();
assert!(
err.contains(":b") && err.contains("is not referenced"),
"{err}"
);
}
#[test]
fn every_name_and_value_being_used_is_accepted() {
parsed_rule(
r##"
[[rules]]
match = "#a = :a"
path = "email"
action = { type = "redact" }
names = { "#a" = "email" }
values = { ":a" = "x" }
"##,
)
.expect("a rule that uses everything it declares is valid");
}
fn parsed_rule(toml_str: &str) -> Result<ValidatedRule, String> {
let config: ImportConfig = toml::from_str(toml_str).map_err(|e| e.to_string())?;
let rule = &config.rules[0];
let condition = condition::parse(&rule.match_expr).map_err(|e| e.to_string())?;
let names = convert_names(&rule.names, 1)?;
let values = convert_values(&rule.values, 1)?;
validate_name_refs(&condition, &names, 1)?;
validate_value_refs(&condition, &values, 1)?;
condition::validate_static(&condition, &values)?;
condition::validate_operand_semantics(&condition, &names, &values)?;
Ok(ValidatedRule {
tables: scoped_tables(&rule.tables, 1)?,
condition,
names,
values,
path: parse_path(&rule.path)?,
action: validate_action(&rule.action, 1)?,
})
}
#[test]
fn test_values_table_feeds_begins_with() {
let rule = parsed_rule(
r#"
[[rules]]
match = "begins_with(pk, :prefix)"
values = { ":prefix" = "USER#" }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap();
let mut item = HashMap::new();
item.insert("pk".to_string(), AttributeValue::S("USER#1".to_string()));
assert!(matches_item(&rule, &item));
item.insert("pk".to_string(), AttributeValue::S("ORDER#1".to_string()));
assert!(!matches_item(&rule, &item));
}
#[test]
fn test_values_convert_by_toml_type() {
let rule = parsed_rule(
r#"
[[rules]]
match = "age > :min AND active = :yes"
values = { ":min" = 18, ":yes" = true }
path = "name"
action = { type = "redact" }
"#,
)
.unwrap();
let values = rule.values.unwrap();
assert_eq!(values[":min"], AttributeValue::N("18".to_string()));
assert_eq!(values[":yes"], AttributeValue::BOOL(true));
}
#[test]
fn test_values_missing_reference_rejected() {
let err = parsed_rule(
r#"
[[rules]]
match = "begins_with(pk, :prefix)"
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains(":prefix"), "{err}");
assert!(err.contains("does not define"), "{err}");
}
#[test]
fn test_values_unused_rejected() {
let err = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(pk)"
values = { ":prefix" = "USER#" }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains(":prefix"), "{err}");
assert!(err.contains("not referenced"), "{err}");
}
#[test]
fn test_values_name_must_start_with_colon() {
let err = parsed_rule(
r#"
[[rules]]
match = "begins_with(pk, :prefix)"
values = { "prefix" = "USER#" }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("must start with ':'"), "{err}");
}
#[test]
fn test_path_rejects_an_expression_attribute_name() {
let err = parse_path("#n").unwrap_err();
assert!(err.contains("names the attribute itself"), "{err}");
assert!(parse_path("name").is_ok());
}
#[test]
fn test_empty_salt_is_rejected() {
unsafe { std::env::set_var("DYNOXIDE_TEST_EMPTY_SALT", "") };
let action = ActionConfig::Hash {
salt_env: Some("DYNOXIDE_TEST_EMPTY_SALT".to_string()),
};
let err = validate_action(&action, 1).unwrap_err();
assert!(err.contains("is empty"), "{err}");
unsafe { std::env::set_var("DYNOXIDE_TEST_EMPTY_SALT", "a-salt-long-enough") };
assert!(validate_action(&action, 1).is_ok());
}
#[test]
fn test_short_salt_is_rejected() {
unsafe { std::env::set_var("DYNOXIDE_TEST_SHORT_SALT", "hunter2") };
let action = ActionConfig::Hash {
salt_env: Some("DYNOXIDE_TEST_SHORT_SALT".to_string()),
};
let err = validate_action(&action, 1).unwrap_err();
assert!(
err.contains("7 bytes"),
"the message names the length: {err}"
);
assert!(err.contains("at least 16"), "{err}");
assert!(
!err.contains("is empty"),
"a short salt is not an empty one: {err}"
);
assert!(
!err.contains("hunter2"),
"the message must not quote the value: {err}"
);
unsafe { std::env::set_var("DYNOXIDE_TEST_SHORT_SALT", "123456789012345") };
assert!(validate_action(&action, 1).is_err(), "15 bytes is short");
unsafe { std::env::set_var("DYNOXIDE_TEST_SHORT_SALT", "1234567890123456") };
assert!(validate_action(&action, 1).is_ok(), "16 bytes is enough");
unsafe { std::env::remove_var("DYNOXIDE_TEST_SHORT_SALT") };
}
#[test]
fn test_short_seed_is_rejected_on_the_same_terms_as_a_salt() {
unsafe { std::env::set_var("DYNOXIDE_TEST_SHORT_SEED", "seed") };
let action = ActionConfig::Fake {
generator: "safe_email".to_string(),
seed_env: Some("DYNOXIDE_TEST_SHORT_SEED".to_string()),
};
let err = validate_action(&action, 1).unwrap_err();
assert!(err.contains("at least 16"), "{err}");
unsafe { std::env::set_var("DYNOXIDE_TEST_SHORT_SEED", "1234567890123456") };
assert!(validate_action(&action, 1).is_ok());
unsafe { std::env::remove_var("DYNOXIDE_TEST_SHORT_SEED") };
}
#[test]
fn test_values_reject_non_finite_floats() {
let err = parsed_rule(
r#"
[[rules]]
match = "amount < :max"
values = { ":max" = inf }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("finite"), "{err}");
}
#[test]
fn test_values_accept_finite_floats_exactly() {
let rule = parsed_rule(
r#"
[[rules]]
match = "amount < :max"
values = { ":max" = 3.14 }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap();
let values = rule.values.expect("a values table");
assert_eq!(values[":max"], AttributeValue::N("3.14".to_string()));
}
#[test]
fn test_values_of_the_wrong_type_are_rejected_up_front() {
let err = parsed_rule(
r#"
[[rules]]
match = "begins_with(pk, :prefix)"
values = { ":prefix" = 123 }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("begins_with"), "{err}");
}
#[test]
fn test_names_table_reaches_a_reserved_word_attribute() {
let rule = parsed_rule(
r##"
[[rules]]
match = "attribute_exists(#n)"
names = { "#n" = "name" }
path = "email"
action = { type = "redact" }
"##,
)
.unwrap();
let mut item = HashMap::new();
item.insert("name".to_string(), AttributeValue::S("Ada".to_string()));
assert!(matches_item(&rule, &item));
assert!(!matches_item(&rule, &HashMap::new()));
}
#[test]
fn test_names_undefined_or_unused_rejected() {
let err = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(#n)"
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("#n"), "{err}");
let err = parsed_rule(
r##"
[[rules]]
match = "attribute_exists(pk)"
names = { "#n" = "name" }
path = "email"
action = { type = "redact" }
"##,
)
.unwrap_err();
assert!(err.contains("not referenced"), "{err}");
let err = parsed_rule(
r#"
[[rules]]
match = "attribute_exists(pk)"
names = { "n" = "name" }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("must start with '#'"), "{err}");
}
#[test]
fn test_values_reject_non_scalars() {
let err = parsed_rule(
r#"
[[rules]]
match = "begins_with(pk, :prefix)"
values = { ":prefix" = ["USER#"] }
path = "email"
action = { type = "redact" }
"#,
)
.unwrap_err();
assert!(err.contains("string, number or boolean"), "{err}");
}
#[test]
fn test_toml_parsing() {
let toml_str = r#"
[[rules]]
match = "attribute_exists(email)"
path = "email"
action = { type = "fake", generator = "safe_email" }
[[rules]]
match = "attribute_exists(phone)"
path = "phone"
action = { type = "mask", keep_last = 4, mask_char = "*" }
[[rules]]
match = "attribute_exists(ssn)"
path = "ssn"
action = { type = "hash", salt_env = "IMPORT_SALT" }
[[rules]]
match = "attribute_exists(notes)"
path = "notes"
action = { type = "redact" }
[consistency]
fields = ["userId", "email"]
"#;
let config: ImportConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.rules.len(), 4);
assert!(config.consistency.is_some());
assert_eq!(
config.consistency.as_ref().unwrap().fields,
vec!["userId", "email"]
);
}
}