use crate::expressions::{resolve_path, set_path};
use crate::types::{AttributeValue, Item};
use super::config::{Salt, ValidatedAction, ValidatedRule, matches_item};
use super::consistency::ConsistencyMap;
use super::notice::Notice;
use hmac::{Hmac, Mac};
type HmacSha256 = Hmac<Sha256>;
use fake::Fake;
use fake::faker::address::en::CityName;
use fake::faker::company::en::CompanyName;
use fake::faker::internet::en::SafeEmail;
use fake::faker::lorem::en::{Sentence, Word};
use fake::faker::name::en::{FirstName, LastName, Name};
use fake::faker::phone_number::en::PhoneNumber;
use fake::rand::rngs::StdRng;
use fake::rand::{Rng, SeedableRng};
use sha2::{Digest, Sha256};
#[derive(Debug, Default, Clone, Copy)]
pub struct RuleWork {
pub matched: usize,
pub path_missing: usize,
pub rewrote: usize,
}
impl RuleWork {
pub fn add(&mut self, landed: RuleWork) {
self.matched += landed.matched;
self.path_missing += landed.path_missing;
self.rewrote += landed.rewrote;
}
}
#[derive(Debug, Default)]
pub struct Rewritten {
pub attributes: std::collections::HashSet<String>,
pub paths: std::collections::HashSet<String>,
}
pub struct RuleTally<'a> {
pub mask_passthroughs: &'a mut std::collections::HashMap<String, usize>,
pub rule_work: &'a mut [RuleWork],
}
pub fn apply_rules(
item: &mut Item,
table: &str,
rules: &[ValidatedRule],
consistency_map: &mut ConsistencyMap,
consistency_fields: &std::collections::HashSet<String>,
key_attrs: &[String],
tally: &mut RuleTally<'_>,
) -> (Vec<Notice>, Rewritten) {
let RuleTally {
mask_passthroughs,
rule_work,
} = tally;
let mut warnings: Vec<Notice> = Vec::new();
let mut rewritten = Rewritten::default();
let mut kept_whole_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut replaced_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
for (rule_idx, rule) in rules.iter().enumerate() {
let work = rule_work
.get_mut(rule_idx)
.expect("one entry per rule, sized by the caller");
if !rule.applies_to(table) {
continue;
}
if !matches_item(rule, item) {
continue;
}
work.matched += 1;
let current_value = resolve_path(item, &rule.path);
if current_value.is_none() {
work.path_missing += 1;
continue; }
let current_value = current_value.unwrap();
let field_name = path_to_field_name(&rule.path);
let is_consistency_field = consistency_fields.contains(&field_name);
let is_deterministic = match &rule.action {
ValidatedAction::Hash { .. } => true,
ValidatedAction::Fake { seed: Some(_), .. } => is_scalar(¤t_value),
_ => false,
};
let new_value = if is_consistency_field && !is_deterministic {
if let Some(cached) = consistency_map.get(&field_name, ¤t_value) {
debug_assert!(
canonical_bytes(&cached) != canonical_bytes(¤t_value),
"the consistency map handed back the value it was asked about"
);
cached
} else {
let generated = generate_value(&rule.action, ¤t_value);
if let Some(cap_warning) = consistency_map.insert(
field_name.clone(),
current_value.clone(),
generated.clone(),
) {
warnings.push(Notice::caution(cap_warning));
}
generated
}
} else {
generate_value(&rule.action, ¤t_value)
};
let kept_whole = match &rule.action {
ValidatedAction::Mask { keep_last, .. } => match ¤t_value {
AttributeValue::S(s) => s.chars().count() <= *keep_last,
AttributeValue::N(n) => n.len() <= *keep_last,
_ => false,
},
_ => false,
};
if key_attrs.contains(&field_name) {
warnings.push(Notice::caution(format!(
"anonymising key attribute '{}': potential for collisions",
field_name
)));
}
match set_path(item, &rule.path, new_value) {
Ok(()) => {
rule_work
.get_mut(rule_idx)
.expect("one entry per rule")
.rewrote += 1;
let path = path_label(&rule.path);
kept_whole_paths.retain(|kept| !covers(&path, kept));
rewritten.paths.insert(path.clone());
if kept_whole {
if !replaced_paths.iter().any(|done| covers(done, &path)) {
kept_whole_paths.insert(path);
}
} else {
replaced_paths.insert(path);
}
rewritten.attributes.insert(field_name);
}
Err(e) => {
warnings.push(Notice::exposure(format!(
"failed to set path '{}', so the original value was left in place: {e}",
field_name
)));
}
}
}
for path in kept_whole_paths {
*mask_passthroughs.entry(path).or_insert(0) += 1;
}
(warnings, rewritten)
}
pub(super) fn path_label(path: &[crate::expressions::PathElement]) -> String {
use crate::expressions::PathElement;
let mut label = String::new();
for element in path {
match element {
PathElement::Attribute(name) => {
if !label.is_empty() {
label.push('.');
}
label.push_str(name);
}
PathElement::Index(i) => {
label.push('[');
label.push_str(&i.to_string());
label.push(']');
}
}
}
label
}
pub(super) fn covers(rewritten: &str, path: &str) -> bool {
path == rewritten
|| path
.strip_prefix(rewritten)
.is_some_and(|rest| rest.starts_with(['.', '[']))
}
pub(super) fn path_to_field_name(path: &[crate::expressions::PathElement]) -> String {
match path.first() {
Some(crate::expressions::PathElement::Attribute(name)) => name.clone(),
_ => String::new(),
}
}
fn generate_value(action: &ValidatedAction, original: &AttributeValue) -> AttributeValue {
match action {
ValidatedAction::Fake { generator, seed } => {
generate_fake(generator, original, seed.as_ref())
}
ValidatedAction::Mask {
keep_last,
mask_char,
} => mask_value(original, *keep_last, *mask_char),
ValidatedAction::Hash { salt } => hash_value(original, salt.as_bytes()),
ValidatedAction::Redact => redact_value(original),
ValidatedAction::Null => AttributeValue::NULL(true),
}
}
fn generate_fake(
generator: &str,
original: &AttributeValue,
seed: Option<&Salt>,
) -> AttributeValue {
let mut rng = seeded_rng(generator, original, seed);
let fake_string: String = match generator {
"safe_email" => widen_email(SafeEmail().fake_with_rng(&mut rng), &mut rng),
"name" => Name().fake_with_rng(&mut rng),
"first_name" => FirstName().fake_with_rng(&mut rng),
"last_name" => LastName().fake_with_rng(&mut rng),
"phone_number" => PhoneNumber().fake_with_rng(&mut rng),
"address" => CityName().fake_with_rng(&mut rng), "company_name" => CompanyName().fake_with_rng(&mut rng),
"sentence" => Sentence(3..8).fake_with_rng(&mut rng),
"word" => Word().fake_with_rng(&mut rng),
_ => format!("[FAKE:{generator}]"),
};
match original {
AttributeValue::S(_) => AttributeValue::S(fake_string),
AttributeValue::N(_) => {
let n: u64 = rng.r#gen();
AttributeValue::N(n.to_string())
}
_ => AttributeValue::S(fake_string),
}
}
fn is_scalar(value: &AttributeValue) -> bool {
matches!(
value,
AttributeValue::S(_) | AttributeValue::N(_) | AttributeValue::B(_)
)
}
fn seeded_rng(generator: &str, original: &AttributeValue, seed: Option<&Salt>) -> StdRng {
match seed {
Some(seed) => {
let mut hasher = Sha256::new();
let mut field = |tag: u8, bytes: &[u8]| {
hasher.update([tag]);
hasher.update((bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
};
field(b'k', seed.as_bytes());
field(b'g', generator.as_bytes());
match original {
AttributeValue::S(s) => field(b's', s.as_bytes()),
AttributeValue::N(n) => match crate::types::validate_dynamo_number(n) {
Ok(()) => field(b'n', crate::types::normalize_dynamo_number(n).as_bytes()),
Err(_) => field(b'x', n.as_bytes()),
},
AttributeValue::B(b) => field(b'b', b),
_ => return StdRng::from_entropy(),
}
let digest = hasher.finalize();
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&digest);
StdRng::from_seed(bytes)
}
None => StdRng::from_entropy(),
}
}
fn widen_email(address: String, rng: &mut StdRng) -> String {
let discriminator: u64 = rng.r#gen();
match address.split_once('@') {
Some((local, domain)) => format!("{local}.{discriminator:016x}@{domain}"),
None => address,
}
}
fn mask_value(original: &AttributeValue, keep_last: usize, mask_char: char) -> AttributeValue {
match original {
AttributeValue::S(s) => {
let char_count = s.chars().count();
if char_count <= keep_last {
AttributeValue::S(s.clone())
} else {
let masked_len = char_count - keep_last;
let byte_offset = s
.char_indices()
.nth(masked_len)
.map(|(i, _)| i)
.unwrap_or(s.len());
let suffix = &s[byte_offset..];
let mut masked =
String::with_capacity(masked_len * mask_char.len_utf8() + suffix.len());
for _ in 0..masked_len {
masked.push(mask_char);
}
masked.push_str(suffix);
AttributeValue::S(masked)
}
}
AttributeValue::N(n) => {
let len = n.len();
if len <= keep_last {
AttributeValue::N(n.clone())
} else {
let masked_len = len - keep_last;
let masked: String =
mask_char.to_string().repeat(masked_len) + &n[len - keep_last..];
AttributeValue::S(masked) }
}
_ => AttributeValue::S(format!("{mask_char}{mask_char}{mask_char}{mask_char}")),
}
}
fn hash_value(original: &AttributeValue, salt: &[u8]) -> AttributeValue {
let mut mac =
HmacSha256::new_from_slice(salt).expect("HMAC-SHA256 accepts a key of any length");
mac.update(&canonical_bytes(original));
let hex = hex_encode(&mac.finalize().into_bytes());
AttributeValue::S(hex)
}
pub(super) fn canonical_bytes(value: &AttributeValue) -> Vec<u8> {
let mut out = Vec::new();
absorb(&mut out, value);
out
}
fn absorb(out: &mut Vec<u8>, value: &AttributeValue) {
fn field(out: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
out.push(tag);
out.extend_from_slice(&(bytes.len() as u64).to_be_bytes());
out.extend_from_slice(bytes);
}
fn header(out: &mut Vec<u8>, tag: u8, members: usize) {
out.push(tag);
out.extend_from_slice(&(members as u64).to_be_bytes());
}
match value {
AttributeValue::S(s) => field(out, b's', s.as_bytes()),
AttributeValue::N(n) => match crate::types::validate_dynamo_number(n) {
Ok(()) => field(
out,
b'n',
crate::types::normalize_dynamo_number(n).as_bytes(),
),
Err(_) => field(out, b'x', n.as_bytes()),
},
AttributeValue::B(b) => field(out, b'b', b),
AttributeValue::BOOL(b) => field(out, b'o', &[u8::from(*b)]),
AttributeValue::NULL(n) => field(out, b'z', &[u8::from(*n)]),
AttributeValue::SS(members) => {
let mut sorted: Vec<&String> = members.iter().collect();
sorted.sort();
sorted.dedup();
header(out, b'P', sorted.len());
for member in sorted {
field(out, b's', member.as_bytes());
}
}
AttributeValue::NS(members) => {
let mut sorted: Vec<String> = members
.iter()
.map(|m| match crate::types::validate_dynamo_number(m) {
Ok(()) => crate::types::normalize_dynamo_number(m),
Err(_) => m.clone(),
})
.collect();
sorted.sort();
sorted.dedup();
header(out, b'Q', sorted.len());
for member in &sorted {
field(out, b'n', member.as_bytes());
}
}
AttributeValue::BS(members) => {
let mut sorted: Vec<&Vec<u8>> = members.iter().collect();
sorted.sort();
sorted.dedup();
header(out, b'R', sorted.len());
for member in sorted {
field(out, b'b', member);
}
}
AttributeValue::L(members) => {
header(out, b'l', members.len());
for member in members {
absorb(out, member);
}
}
AttributeValue::M(entries) => {
let mut sorted: Vec<(&String, &AttributeValue)> = entries.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
header(out, b'm', sorted.len());
for (name, member) in sorted {
field(out, b'k', name.as_bytes());
absorb(out, member);
}
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
fn redact_value(original: &AttributeValue) -> AttributeValue {
match original {
AttributeValue::S(_) => AttributeValue::S("[REDACTED]".to_string()),
AttributeValue::N(_) => AttributeValue::S("[REDACTED]".to_string()),
AttributeValue::B(_) => AttributeValue::B(Vec::new()),
AttributeValue::L(_) => AttributeValue::L(Vec::new()),
AttributeValue::M(_) => AttributeValue::M(std::collections::HashMap::new()),
_ => AttributeValue::S("[REDACTED]".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_value() {
let result = mask_value(&AttributeValue::S("1234567890".to_string()), 4, '*');
assert_eq!(result, AttributeValue::S("******7890".to_string()));
}
#[test]
fn test_mask_value_multibyte_utf8() {
let result = mask_value(&AttributeValue::S("héllo".to_string()), 2, '*');
assert_eq!(result, AttributeValue::S("***lo".to_string()));
let result = mask_value(&AttributeValue::S("日本語".to_string()), 1, '*');
assert_eq!(result, AttributeValue::S("**語".to_string()));
}
#[test]
fn test_mask_short_value() {
let result = mask_value(&AttributeValue::S("ab".to_string()), 4, '*');
assert_eq!(result, AttributeValue::S("ab".to_string()));
}
#[test]
fn test_hash_value_deterministic() {
let salt = b"test_salt";
let v1 = hash_value(&AttributeValue::S("hello".to_string()), salt);
let v2 = hash_value(&AttributeValue::S("hello".to_string()), salt);
assert_eq!(v1, v2);
}
#[test]
fn test_hash_value_different_with_different_salt() {
let v1 = hash_value(&AttributeValue::S("hello".to_string()), b"salt1");
let v2 = hash_value(&AttributeValue::S("hello".to_string()), b"salt2");
assert_ne!(v1, v2);
}
#[test]
fn test_salt_and_value_do_not_share_a_boundary() {
let shifted = hash_value(&AttributeValue::S("cd".to_string()), b"ab");
let other = hash_value(&AttributeValue::S("bcd".to_string()), b"a");
assert_ne!(
shifted, other,
"the salt must not run into the value as one byte string"
);
}
fn test_rule(path: &str, action: ValidatedAction) -> ValidatedRule {
ValidatedRule {
condition: crate::expressions::condition::parse(
"attribute_not_exists(__no_item_has_this__)",
)
.expect("a condition true of every item"),
names: None,
values: None,
path: crate::import::config::parse_path(path).expect("a valid path"),
action,
tables: None,
}
}
fn item_with_email() -> Item {
let mut m = Item::new();
m.insert(
"email".to_string(),
AttributeValue::S("real@example.com".to_string()),
);
m
}
fn work_for(rules: &[ValidatedRule]) -> Vec<RuleWork> {
vec![RuleWork::default(); rules.len()]
}
fn apply_for_test(
item: &mut Item,
rules: &[ValidatedRule],
work: &mut [RuleWork],
passthroughs: &mut std::collections::HashMap<String, usize>,
) {
apply_rules(
item,
"Users",
rules,
&mut ConsistencyMap::new(),
&std::collections::HashSet::new(),
&[],
&mut RuleTally {
mask_passthroughs: passthroughs,
rule_work: work,
},
);
}
fn apply_with_consistency(
item: &mut Item,
rules: &[ValidatedRule],
map: &mut ConsistencyMap,
fields: &[&str],
work: &mut [RuleWork],
passthroughs: &mut std::collections::HashMap<String, usize>,
) {
let fields = fields.iter().map(|f| f.to_string()).collect();
apply_rules(
item,
"Users",
rules,
map,
&fields,
&[],
&mut RuleTally {
mask_passthroughs: passthroughs,
rule_work: work,
},
);
}
#[test]
fn a_rule_scoped_to_another_table_neither_matches_nor_counts() {
let mut rule = test_rule("email", ValidatedAction::Redact);
rule.tables = Some(vec!["Orders".to_string()]);
let rules = [rule];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = item_with_email();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(work[0].matched, 0, "the rule is not for this table");
assert_eq!(
it.get("email"),
Some(&AttributeValue::S("real@example.com".to_string())),
"so it changed nothing here"
);
}
#[test]
fn a_rule_whose_path_is_absent_records_no_work() {
let rules = [test_rule("emial", ValidatedAction::Redact)];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = item_with_email();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(work[0].matched, 1, "the rule still matched the item");
assert_eq!(work[0].path_missing, 1, "but the attribute is not there");
assert_eq!(work[0].rewrote, 0, "so nothing was rewritten");
assert_eq!(
it.get("email"),
Some(&AttributeValue::S("real@example.com".to_string())),
"the real value is untouched, which is the whole problem"
);
}
#[test]
fn a_rule_that_fires_records_the_work_it_did() {
let rules = [test_rule("email", ValidatedAction::Redact)];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = item_with_email();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(work[0].matched, 1);
assert_eq!(work[0].path_missing, 0);
assert_eq!(work[0].rewrote, 1);
}
#[test]
fn a_mask_that_kept_a_short_value_is_counted_once_per_item() {
let short = || {
let mut m = Item::new();
m.insert("code".to_string(), AttributeValue::S("ab".to_string()));
m
};
let mask = || ValidatedAction::Mask {
keep_last: 4,
mask_char: '*',
};
let rules = [test_rule("code", mask()), test_rule("code", mask())];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = short();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(
passthroughs.get("code"),
Some(&1),
"one item kept its value, however many rules looked at it"
);
}
#[test]
fn a_value_a_mask_kept_whole_but_a_later_rule_replaced_is_not_a_passthrough() {
let mut it = Item::new();
it.insert("code".to_string(), AttributeValue::S("ab".to_string()));
let rules = [
test_rule(
"code",
ValidatedAction::Mask {
keep_last: 4,
mask_char: '*',
},
),
test_rule("code", ValidatedAction::Redact),
];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(
it.get("code"),
Some(&AttributeValue::S("[REDACTED]".to_string())),
"the later rule won"
);
assert!(
passthroughs.is_empty(),
"so there is no pass-through to report: {passthroughs:?}"
);
}
#[test]
fn a_short_value_a_mask_kept_does_not_reach_a_later_rule_through_the_cache() {
let mut it = Item::new();
it.insert("code".to_string(), AttributeValue::S("ab".to_string()));
let rules = [
test_rule("code", short_mask()),
test_rule(
"code",
ValidatedAction::Fake {
generator: "word".to_string(),
seed: None,
},
),
];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut map = ConsistencyMap::new();
apply_with_consistency(
&mut it,
&rules,
&mut map,
&["code"],
&mut work,
&mut passthroughs,
);
let survived = it.get("code") == Some(&AttributeValue::S("ab".to_string()));
assert!(
!survived || !passthroughs.is_empty(),
"the original reached the output and nothing reported it: {it:?} {passthroughs:?}"
);
}
#[test]
fn a_mask_over_a_map_is_not_a_passthrough() {
let rules = [test_rule(
"profile",
ValidatedAction::Mask {
keep_last: 4,
mask_char: '*',
},
)];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = Item::new();
let mut inner = std::collections::HashMap::new();
inner.insert("a".to_string(), AttributeValue::S("secret".to_string()));
it.insert("profile".to_string(), AttributeValue::M(inner));
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(
passthroughs.get("profile"),
None,
"the map was replaced, so nothing was kept as it arrived"
);
}
fn profile_item() -> Item {
let mut inner = std::collections::HashMap::new();
inner.insert("email".to_string(), AttributeValue::S("ab@x".to_string()));
inner.insert(
"phone".to_string(),
AttributeValue::S("07700900000".to_string()),
);
let mut it = Item::new();
it.insert("profile".to_string(), AttributeValue::M(inner));
it
}
fn short_mask() -> ValidatedAction {
ValidatedAction::Mask {
keep_last: 4,
mask_char: '*',
}
}
#[test]
fn a_rule_on_a_sibling_path_does_not_clear_a_nested_mask_passthrough() {
let rules = [
test_rule("profile.email", short_mask()),
test_rule("profile.phone", ValidatedAction::Redact),
];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = profile_item();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(
passthroughs.get("profile.email"),
Some(&1),
"the address was kept and nothing since replaced it: {passthroughs:?}"
);
}
#[test]
fn a_rule_on_the_map_above_a_nested_mask_clears_its_passthrough() {
let rules = [
test_rule("profile.email", short_mask()),
test_rule("profile", ValidatedAction::Redact),
];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = profile_item();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert!(passthroughs.is_empty(), "{passthroughs:?}");
}
#[test]
fn a_mask_that_keeps_the_whole_of_an_earlier_rules_output_is_not_a_passthrough() {
let rules = [
test_rule("email", ValidatedAction::Redact),
test_rule(
"email",
ValidatedAction::Mask {
keep_last: 20,
mask_char: '*',
},
),
];
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
let mut it = item_with_email();
apply_for_test(&mut it, &rules, &mut work, &mut passthroughs);
assert_eq!(
it.get("email"),
Some(&AttributeValue::S("[REDACTED]".to_string())),
"the mask kept the redaction whole"
);
assert!(passthroughs.is_empty(), "{passthroughs:?}");
}
#[test]
fn a_path_covers_itself_and_what_sits_beneath_it_but_not_a_sibling() {
assert!(covers("profile", "profile"));
assert!(covers("profile", "profile.email"));
assert!(covers("tags", "tags[0]"));
assert!(!covers("profile.phone", "profile.email"));
assert!(!covers("profile", "profiles"));
assert!(!covers("profile.email", "profile"));
assert_eq!(path_label(&parse_path_for_test("a.b[2].c")), "a.b[2].c");
}
fn parse_path_for_test(path: &str) -> Vec<crate::expressions::PathElement> {
crate::import::config::parse_path(path).expect("a valid path")
}
#[test]
fn one_number_has_one_encoding_however_it_is_spelled() {
let salt = b"a-salt-long-enough";
for other in ["1.0", "01", "0.1e1", "1.00"] {
assert_eq!(
hash_value(&AttributeValue::N("1".to_string()), salt),
hash_value(&AttributeValue::N(other.to_string()), salt),
"N(\"1\") and N({other:?}) are one number"
);
}
assert_ne!(
hash_value(&AttributeValue::N("1".to_string()), salt),
hash_value(&AttributeValue::N("2".to_string()), salt),
"but two numbers stay two"
);
}
#[test]
fn a_value_that_is_not_a_number_keeps_its_own_pseudonym() {
let salt = b"a-salt-long-enough";
let zero = hash_value(&AttributeValue::N("0".to_string()), salt);
for bad in ["abc", "NaN", "", "not-a-number", "1.2.3"] {
assert_ne!(
hash_value(&AttributeValue::N(bad.to_string()), salt),
zero,
"N({bad:?}) must not land on the pseudonym for zero"
);
}
assert_ne!(
hash_value(&AttributeValue::N("abc".to_string()), salt),
hash_value(&AttributeValue::N("NaN".to_string()), salt),
"and two different broken values stay two"
);
}
#[test]
fn an_exponent_dynamodb_would_reject_is_not_expanded() {
let started = std::time::Instant::now();
let out = crate::types::normalize_dynamo_number("1e-2000000000");
assert!(
out.len() < 64,
"an unacceptable exponent must not be expanded: got {} chars",
out.len()
);
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"and it must not take a second to decide that"
);
}
#[test]
fn a_binary_set_is_a_set_too() {
let salt = b"a-salt-long-enough";
assert_eq!(
hash_value(&AttributeValue::BS(vec![vec![1, 2]]), salt),
hash_value(&AttributeValue::BS(vec![vec![1, 2], vec![1, 2]]), salt)
);
}
#[test]
fn a_set_is_a_set_whatever_the_export_repeated() {
let salt = b"a-salt-long-enough";
assert_eq!(
hash_value(&AttributeValue::SS(vec!["a".into()]), salt),
hash_value(&AttributeValue::SS(vec!["a".into(), "a".into()]), salt)
);
assert_eq!(
hash_value(&AttributeValue::NS(vec!["1".into()]), salt),
hash_value(&AttributeValue::NS(vec!["1".into(), "1.0".into()]), salt),
"and two spellings of one number are one member"
);
}
#[test]
fn a_generated_number_has_room_not_to_collide() {
let mut seen = std::collections::HashSet::new();
for n in 0..2000 {
let original = AttributeValue::N(n.to_string());
if let AttributeValue::N(drawn) = generate_fake("word", &original, None) {
seen.insert(drawn);
}
}
assert_eq!(seen.len(), 2000, "2000 draws must not repeat");
assert!(
seen.iter().any(|d| d.len() > 10),
"a u64 draw has to reach past ten digits sometimes: the old \
four-digit range never could"
);
}
#[test]
fn test_hash_is_stable_over_a_map() {
let entries = || {
let mut m = std::collections::HashMap::new();
for k in [
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
] {
m.insert(k.to_string(), AttributeValue::S(format!("{k}-value")));
}
AttributeValue::M(m)
};
let salt = b"a-salt-long-enough";
assert_eq!(
hash_value(&entries(), salt),
hash_value(&entries(), salt),
"one map value must give one pseudonym"
);
}
#[test]
fn test_hash_is_stable_over_a_nested_map() {
let nested = || {
let mut inner = std::collections::HashMap::new();
for k in ["one", "two", "three", "four", "five", "six"] {
inner.insert(k.to_string(), AttributeValue::N("1".to_string()));
}
AttributeValue::L(vec![
AttributeValue::S("first".to_string()),
AttributeValue::M(inner),
])
};
let salt = b"a-salt-long-enough";
assert_eq!(hash_value(&nested(), salt), hash_value(&nested(), salt));
}
#[test]
fn test_hash_is_stable_over_a_set_whatever_order_it_arrives_in() {
let salt = b"a-salt-long-enough";
let forwards = AttributeValue::SS(vec!["a".to_string(), "b".to_string()]);
let backwards = AttributeValue::SS(vec!["b".to_string(), "a".to_string()]);
assert_eq!(hash_value(&forwards, salt), hash_value(&backwards, salt));
}
#[test]
fn test_a_list_keeps_the_order_it_arrived_in() {
let salt = b"a-salt-long-enough";
let forwards = AttributeValue::L(vec![
AttributeValue::S("a".to_string()),
AttributeValue::S("b".to_string()),
]);
let backwards = AttributeValue::L(vec![
AttributeValue::S("b".to_string()),
AttributeValue::S("a".to_string()),
]);
assert_ne!(hash_value(&forwards, salt), hash_value(&backwards, salt));
}
#[test]
fn test_canonical_encoding_keeps_neighbouring_fields_apart() {
let one = |k: &str, v: &str| {
let mut m = std::collections::HashMap::new();
m.insert(k.to_string(), AttributeValue::S(v.to_string()));
AttributeValue::M(m)
};
assert_ne!(
canonical_bytes(&one("ab", "c")),
canonical_bytes(&one("a", "bc"))
);
}
#[test]
fn test_containers_of_the_same_members_encode_apart() {
let member = || AttributeValue::S("x".to_string());
assert_ne!(
canonical_bytes(&AttributeValue::L(vec![member()])),
canonical_bytes(&AttributeValue::SS(vec!["x".to_string()]))
);
assert_ne!(
canonical_bytes(&AttributeValue::M(std::collections::HashMap::new())),
canonical_bytes(&AttributeValue::L(Vec::new()))
);
assert_ne!(
canonical_bytes(&AttributeValue::BOOL(false)),
canonical_bytes(&AttributeValue::NULL(true))
);
}
#[test]
fn test_hash_over_binary_is_stable_and_distinct() {
let salt = b"a-salt-long-enough";
let bytes = AttributeValue::B(vec![0x00, 0xff, 0x10]);
assert_eq!(hash_value(&bytes, salt), hash_value(&bytes, salt));
assert_ne!(
hash_value(&bytes, salt),
hash_value(&AttributeValue::S("\u{0}\u{ff}\u{10}".to_string()), salt),
"binary must not land on the string of the same characters"
);
}
#[test]
fn test_string_and_number_pseudonymise_differently() {
let s = hash_value(&AttributeValue::S("123".to_string()), b"a-salt-long-enough");
let n = hash_value(&AttributeValue::N("123".to_string()), b"a-salt-long-enough");
assert_ne!(s, n, "type must be part of the derivation");
}
#[test]
fn test_hash_value_is_hmac_sha256_of_the_tagged_value() {
let salt = b"a-salt-long-enough";
let mut expected = <HmacSha256 as Mac>::new_from_slice(salt).unwrap();
expected.update(b"s");
expected.update(&(5u64).to_be_bytes());
expected.update(b"hello");
let expected = hex_encode(&expected.finalize().into_bytes());
assert_eq!(
hash_value(&AttributeValue::S("hello".to_string()), salt),
AttributeValue::S(expected)
);
}
#[test]
fn test_redact_value() {
assert_eq!(
redact_value(&AttributeValue::S("secret".to_string())),
AttributeValue::S("[REDACTED]".to_string())
);
assert_eq!(
redact_value(&AttributeValue::L(vec![AttributeValue::S("a".to_string())])),
AttributeValue::L(Vec::new())
);
}
fn seed(bytes: &str) -> Salt {
Salt::new(bytes.as_bytes().to_vec())
}
#[test]
fn seeded_fake_is_a_function_of_the_input() {
let s = seed("a-secret");
let alice = AttributeValue::S("alice@example.com".to_string());
let bob = AttributeValue::S("bob@example.com".to_string());
let first = generate_fake("safe_email", &alice, Some(&s));
for _ in 0..5 {
assert_eq!(generate_fake("safe_email", &alice, Some(&s)), first);
}
assert_ne!(generate_fake("safe_email", &bob, Some(&s)), first);
let other = seed("another-secret");
assert_ne!(generate_fake("safe_email", &alice, Some(&other)), first);
}
#[test]
fn a_seeded_fake_gives_one_number_one_value_however_it_is_spelled() {
let rules = [test_rule(
"customerId",
ValidatedAction::Fake {
generator: "word".to_string(),
seed: Some(seed("a-secret")),
},
)];
let mut map = ConsistencyMap::new();
let mut drawn = Vec::new();
for spelling in ["1", "1.0", "0.1e1", "01"] {
let mut it = Item::new();
it.insert(
"customerId".to_string(),
AttributeValue::N(spelling.to_string()),
);
let mut work = work_for(&rules);
let mut passthroughs = std::collections::HashMap::new();
apply_with_consistency(
&mut it,
&rules,
&mut map,
&["customerId"],
&mut work,
&mut passthroughs,
);
drawn.push(it.get("customerId").cloned());
}
assert!(
drawn.iter().all(|d| d == &drawn[0]),
"one number took more than one value: {drawn:?}"
);
}
#[test]
fn a_seeded_fake_over_a_broken_number_keeps_its_own_draw() {
let s = seed("a-secret");
let n = |v: &str| AttributeValue::N(v.to_string());
let zero = derived("word", &n("0"), &s);
for bad in ["abc", "NaN", "", "1.2.3"] {
assert_ne!(
derived("word", &n(bad), &s),
zero,
"N({bad:?}) must not draw as zero"
);
}
assert_ne!(
derived("word", &n("abc"), &s),
derived("word", &n("NaN"), &s),
"and two different broken values stay two"
);
}
fn derived(generator: &str, original: &AttributeValue, s: &Salt) -> u64 {
seeded_rng(generator, original, Some(s)).r#gen()
}
#[test]
fn the_derivation_separates_its_fields() {
let text = |v: &str| AttributeValue::S(v.into());
assert_ne!(
derived("word", &text("gsafe_emailsx"), &seed("a")),
derived("safe_email", &text("x"), &seed("agwords")),
"field lengths must be part of the input"
);
let s = seed("a-secret");
assert_ne!(
derived("word", &text("123"), &s),
derived("word", &AttributeValue::N("123".into()), &s),
"the attribute type is part of the input"
);
assert_ne!(
derived("word", &text("x"), &s),
derived("safe_email", &text("x"), &s),
"the generator name is part of the input"
);
}
#[test]
fn the_email_suffix_is_a_full_64_bits() {
let s = seed("a-secret");
let mut high_halves = std::collections::HashSet::new();
for n in 0..32 {
let original = AttributeValue::S(format!("person{n}@b.c"));
match generate_fake("safe_email", &original, Some(&s)) {
AttributeValue::S(v) => {
let (local, _) = v.split_once('@').expect("an address");
let suffix = local.rsplit('.').next().expect("a suffix");
assert_eq!(suffix.len(), 16, "expected 16 hex characters in {v}");
assert!(
suffix.chars().all(|c| c.is_ascii_hexdigit()),
"expected hex in {v}"
);
high_halves.insert(suffix[..8].to_string());
}
other => panic!("expected a string, got {other:?}"),
}
}
assert!(
high_halves.len() > 1,
"the top 32 bits never varied across 32 inputs: {high_halves:?}"
);
}
#[test]
fn a_value_whose_bytes_are_not_stable_does_not_claim_determinism() {
let s = seed("a-secret");
let mut map = std::collections::HashMap::new();
map.insert("a".to_string(), AttributeValue::S("x".to_string()));
map.insert("b".to_string(), AttributeValue::S("y".to_string()));
let value = AttributeValue::M(map);
let mut seen = std::collections::HashSet::new();
for _ in 0..20 {
seen.insert(format!("{:?}", generate_fake("word", &value, Some(&s))));
}
assert!(
seen.len() > 1,
"a non-scalar should draw fresh rather than pretend to be deterministic"
);
}
#[test]
fn unseeded_fake_still_re_rolls() {
let alice = AttributeValue::S("alice@example.com".to_string());
let mut seen = std::collections::HashSet::new();
for _ in 0..20 {
seen.insert(format!("{:?}", generate_fake("safe_email", &alice, None)));
}
assert!(seen.len() > 1, "without a seed each call should re-roll");
}
#[test]
fn generated_emails_have_room_to_avoid_collisions() {
let s = seed("a-secret");
let mut seen = std::collections::HashSet::new();
for n in 0..2_000 {
let original = AttributeValue::S(format!("person{n}@real.example"));
match generate_fake("safe_email", &original, Some(&s)) {
AttributeValue::S(v) => {
assert!(v.contains('@'), "still an address: {v}");
assert!(seen.insert(v), "collision within 2000 values");
}
other => panic!("expected a string, got {other:?}"),
}
}
}
#[test]
fn a_non_address_generator_is_left_exactly_as_the_generator_produced_it() {
let s = seed("a-secret");
let original = AttributeValue::S("x".to_string());
let expected: String = Word().fake_with_rng(&mut seeded_rng("word", &original, Some(&s)));
match generate_fake("word", &original, Some(&s)) {
AttributeValue::S(w) => {
assert_eq!(w, expected, "widening must not touch a non-address")
}
other => panic!("expected a string, got {other:?}"),
}
}
#[test]
fn test_generate_fake_preserves_type() {
let result = generate_fake(
"safe_email",
&AttributeValue::S("old@example.com".to_string()),
None,
);
assert!(matches!(result, AttributeValue::S(_)), "got {result:?}");
let result = generate_fake("name", &AttributeValue::N("42".to_string()), None);
assert!(matches!(result, AttributeValue::N(_)), "got {result:?}");
}
}