use crate::category::Category;
use hmac::{Hmac, Mac};
use rand::Rng;
use sha2::Sha256;
use zeroize::Zeroize;
pub trait ReplacementGenerator: Send + Sync {
fn generate(&self, category: &Category, original: &str) -> String;
}
pub struct HmacGenerator {
key: [u8; 32],
}
impl Drop for HmacGenerator {
fn drop(&mut self) {
self.key.zeroize();
}
}
impl HmacGenerator {
#[must_use]
pub fn new(key: [u8; 32]) -> Self {
Self { key }
}
pub fn from_slice(bytes: &[u8]) -> crate::error::Result<Self> {
if bytes.len() != 32 {
return Err(crate::error::SanitizeError::InvalidSeedLength(bytes.len()));
}
let mut key = [0u8; 32];
key.copy_from_slice(bytes);
Ok(Self { key })
}
fn derive(&self, category: &Category, original: &str) -> [u8; 32] {
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
let tag = category.domain_tag_hmac();
mac.update(tag.as_bytes());
mac.update(b"\x00"); mac.update(original.as_bytes());
let result = mac.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result.into_bytes());
out
}
}
impl ReplacementGenerator for HmacGenerator {
fn generate(&self, category: &Category, original: &str) -> String {
let hash = self.derive(category, original);
format_replacement(category, &hash, original)
}
}
pub struct RandomGenerator;
impl RandomGenerator {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for RandomGenerator {
fn default() -> Self {
Self::new()
}
}
impl ReplacementGenerator for RandomGenerator {
fn generate(&self, category: &Category, original: &str) -> String {
let mut rng = rand::thread_rng();
let mut hash = [0u8; 32];
rng.fill(&mut hash);
format_replacement(category, &hash, original)
}
}
fn format_replacement(category: &Category, hash: &[u8; 32], original: &str) -> String {
let target = original.len();
if target == 0 {
return String::new();
}
match category {
Category::Email => format_email_lp(hash, original, target),
Category::Name => format_name_lp(hash, target),
Category::Phone | Category::CreditCard | Category::IpV4 => {
format_digits_lp(hash, original, target)
}
Category::IpV6 | Category::MacAddress | Category::Uuid | Category::ContainerId => {
format_hex_digits_lp(hash, original, target)
}
Category::Ssn => format_ssn_lp(hash, original, target),
Category::Hostname => format_hostname_lp(hash, original, target),
Category::Jwt => format_jwt_lp(hash, original, target),
Category::FilePath => format_filepath_lp(hash, original, target),
Category::WindowsSid => format_windows_sid_lp(hash, original, target),
Category::Url => format_url_lp(hash, original, target),
Category::AwsArn => format_arn_lp(hash, original, target),
Category::AzureResourceId => format_azure_resource_id_lp(hash, original, target),
Category::AuthToken | Category::Custom(_) => format_custom_lp(hash, target),
}
}
fn pad_or_truncate(s: &str, target: usize, hash: &[u8; 32]) -> String {
let slen = s.len();
if slen == target {
return s.to_string();
}
if slen > target {
return s[..target].to_string();
}
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
buf.push_str(s);
for i in 0..target.saturating_sub(slen) {
buf.push(hex_bytes[i % 64] as char);
}
buf
}
fn format_email_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let domain = original
.rfind('@')
.map_or("x.co", |pos| &original[pos + 1..]);
let at_domain = 1 + domain.len(); if target <= at_domain {
return pad_or_truncate("", target, hash);
}
let user_len = target - at_domain;
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
for i in 0..user_len {
buf.push(hex_bytes[i % 64] as char);
}
buf.push('@');
buf.push_str(domain);
buf
}
fn format_name_lp(hash: &[u8; 32], target: usize) -> String {
let raw = format_name(hash);
pad_or_truncate(&raw, target, hash)
}
fn format_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
let mut had_digit = false;
for ch in original.chars() {
if ch.is_ascii_digit() {
buf.push((b'0' + hash[hi % 32] % 10) as char);
hi += 1;
had_digit = true;
} else {
buf.push(ch);
}
}
if !had_digit {
return pad_or_truncate("", target, hash);
}
if buf.len() != target {
return pad_or_truncate(&buf, target, hash);
}
buf
}
fn format_hex_digits_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
let mut had_hex = false;
for ch in original.chars() {
if ch.is_ascii_hexdigit() {
let nibble = hash[hi % 32] % 16;
let replacement = if ch.is_ascii_uppercase() {
b"0123456789ABCDEF"[nibble as usize]
} else {
b"0123456789abcdef"[nibble as usize]
};
buf.push(replacement as char);
hi += 1;
had_hex = true;
} else {
buf.push(ch);
}
}
if !had_hex {
return pad_or_truncate("", target, hash);
}
buf
}
fn format_ssn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let has_digit = original.chars().any(|c| c.is_ascii_digit());
if !has_digit {
return pad_or_truncate("", target, hash);
}
let mut buf = String::with_capacity(target);
let mut digit_idx = 0usize;
for ch in original.chars() {
if ch.is_ascii_digit() {
if digit_idx < 3 {
buf.push('0');
} else {
buf.push((b'0' + hash[(digit_idx - 3) % 32] % 10) as char);
}
digit_idx += 1;
} else {
buf.push(ch);
}
}
buf
}
fn format_hostname_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let suffix = original.find('.').map_or("", |p| &original[p..]);
let prefix_len = target.saturating_sub(suffix.len());
if prefix_len == 0 {
return pad_or_truncate("", target, hash);
}
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
for i in 0..prefix_len {
buf.push(hex_bytes[i % 64] as char);
}
buf.push_str(suffix);
buf
}
fn format_custom_lp(hash: &[u8; 32], target: usize) -> String {
let prefix = "__SANITIZED_";
let suffix = "__";
let overhead = prefix.len() + suffix.len(); let hex = hex_encode(hash);
if target <= overhead {
return pad_or_truncate("", target, hash);
}
let hex_len = target - overhead;
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
buf.push_str(prefix);
for i in 0..hex_len {
buf.push(hex_bytes[i % 64] as char);
}
buf.push_str(suffix);
buf
}
fn format_jwt_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
const B64URL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
let mut had_b64 = false;
for ch in original.chars() {
if ch == '.' || ch == '=' {
buf.push(ch);
} else if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
hi += 1;
had_b64 = true;
} else {
for _ in 0..ch.len_utf8() {
buf.push(B64URL[hash[hi % 32] as usize % B64URL.len()] as char);
hi += 1;
}
had_b64 = true;
}
}
if !had_b64 {
return pad_or_truncate("", target, hash);
}
buf
}
fn format_filepath_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let last_sep = original.rfind(['/', '\\']).map_or(0, |p| p + 1);
let filename = &original[last_sep..];
let ext_start = filename.rfind('.').filter(|&p| p > 0).map(|p| last_sep + p);
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
for (i, ch) in original.char_indices() {
if matches!(ch, '/' | '\\') || ext_start.is_some_and(|es| i >= es) {
buf.push(ch);
} else {
for _ in 0..ch.len_utf8() {
buf.push(hex_bytes[hi % 64] as char);
hi += 1;
}
}
}
if buf.len() != target {
return pad_or_truncate(&buf, target, hash);
}
buf
}
fn format_windows_sid_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
let has_digit = original.chars().any(|c| c.is_ascii_digit());
if !has_digit {
return pad_or_truncate("", target, hash);
}
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
for ch in original.chars() {
if ch == 'S' || ch == '-' {
buf.push(ch);
} else if ch.is_ascii_digit() {
buf.push((b'0' + hash[hi % 32] % 10) as char);
hi += 1;
} else {
for _ in 0..ch.len_utf8() {
buf.push((b'0' + hash[hi % 32] % 10) as char);
hi += 1;
}
}
}
buf
}
fn format_preserving_hex_lp(
hash: &[u8; 32],
original: &str,
target: usize,
is_structural: impl Fn(char) -> bool,
) -> Option<String> {
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
let mut had_content = false;
for ch in original.chars() {
if is_structural(ch) {
buf.push(ch);
} else {
for _ in 0..ch.len_utf8() {
buf.push(hex_bytes[hi % 64] as char);
hi += 1;
}
had_content = true;
}
}
had_content.then_some(buf)
}
fn format_url_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
format_preserving_hex_lp(hash, original, target, |ch| "/:?=&#@.".contains(ch))
.unwrap_or_else(|| pad_or_truncate("", target, hash))
}
fn format_arn_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
format_preserving_hex_lp(hash, original, target, |ch| ch == ':' || ch == '/')
.unwrap_or_else(|| pad_or_truncate("", target, hash))
}
fn format_azure_resource_id_lp(hash: &[u8; 32], original: &str, target: usize) -> String {
const KNOWN_SEGMENTS: &[&str] = &[
"subscriptions",
"resourceGroups",
"resourcegroups",
"providers",
];
let hex = hex_encode(hash);
let hex_bytes = hex.as_bytes();
let mut buf = String::with_capacity(target);
let mut hi = 0usize;
let parts: Vec<&str> = original.split('/').collect();
for (pi, part) in parts.iter().enumerate() {
if pi > 0 {
buf.push('/');
}
if part.is_empty() || KNOWN_SEGMENTS.contains(part) || part.contains('.') {
buf.push_str(part);
} else {
for ch in part.chars() {
for _ in 0..ch.len_utf8() {
buf.push(hex_bytes[hi % 64] as char);
hi += 1;
}
}
}
}
if buf.len() != target {
return pad_or_truncate(&buf, target, hash);
}
buf
}
fn format_name(hash: &[u8; 32]) -> String {
const FIRST: &[&str] = &[
"Alex", "Blake", "Casey", "Dana", "Ellis", "Finley", "Gray", "Harper", "Ira", "Jordan",
"Kai", "Lane", "Morgan", "Noel", "Oakley", "Parker", "Quinn", "Reese", "Sage", "Taylor",
"Uri", "Val", "Wren", "Xen", "Yael", "Zion", "Arden", "Blair", "Corin", "Drew", "Emery",
"Frost",
];
const LAST: &[&str] = &[
"Ashford",
"Blackwell",
"Crawford",
"Dalton",
"Eastwood",
"Fairbanks",
"Garrison",
"Hartley",
"Irvine",
"Jensen",
"Kendrick",
"Langley",
"Mercer",
"Newland",
"Oakwood",
"Preston",
"Quinlan",
"Redmond",
"Shepard",
"Thornton",
"Underwood",
"Vance",
"Whitmore",
"Xavier",
"Yardley",
"Zimmer",
"Ashton",
"Beckett",
"Calloway",
"Dempsey",
"Eldridge",
"Fletcher",
];
let fi = hash[0] as usize % FIRST.len();
let li = hash[1] as usize % LAST.len();
format!("{} {}", FIRST[fi], LAST[li])
}
fn hex_encode(bytes: &[u8; 32]) -> String {
use std::fmt::Write;
let mut hex = String::with_capacity(64);
for b in bytes {
let _ = write!(hex, "{:02x}", b);
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hmac_deterministic_same_input() {
let gen = HmacGenerator::new([42u8; 32]);
let a = gen.generate(&Category::Email, "alice@corp.com");
let b = gen.generate(&Category::Email, "alice@corp.com");
assert_eq!(a, b, "same seed + same input must produce same output");
}
#[test]
fn hmac_different_inputs_differ() {
let gen = HmacGenerator::new([42u8; 32]);
let a = gen.generate(&Category::Email, "alice@corp.com");
let b = gen.generate(&Category::Email, "bob@corp.com");
assert_ne!(a, b);
}
#[test]
fn hmac_different_seeds_differ() {
let g1 = HmacGenerator::new([1u8; 32]);
let g2 = HmacGenerator::new([2u8; 32]);
let a = g1.generate(&Category::Email, "alice@corp.com");
let b = g2.generate(&Category::Email, "alice@corp.com");
assert_ne!(a, b);
}
#[test]
fn hmac_different_categories_differ() {
let gen = HmacGenerator::new([42u8; 32]);
let a = gen.generate(&Category::Email, "test");
let b = gen.generate(&Category::Name, "test");
assert_ne!(a, b, "different categories must produce different outputs");
}
#[test]
fn email_format() {
let gen = HmacGenerator::new([0u8; 32]);
let orig = "alice@corp.com";
let out = gen.generate(&Category::Email, orig);
assert!(out.contains('@'), "email must contain @");
assert!(out.ends_with("@corp.com"), "email must preserve domain");
assert_eq!(out.len(), orig.len(), "email must preserve length");
}
#[test]
fn ipv4_format() {
let gen = HmacGenerator::new([0u8; 32]);
let orig = "192.168.1.1";
let out = gen.generate(&Category::IpV4, orig);
let parts: Vec<&str> = out.split('.').collect();
assert_eq!(parts.len(), 4);
assert_eq!(out.len(), orig.len(), "ipv4 must preserve length");
}
#[test]
fn ssn_format() {
let gen = HmacGenerator::new([7u8; 32]);
let orig = "123-45-6789";
let out = gen.generate(&Category::Ssn, orig);
assert!(out.starts_with("000-"), "SSN must start with 000");
assert_eq!(out.len(), orig.len(), "SSN must preserve length");
}
#[test]
fn phone_format() {
let gen = HmacGenerator::new([3u8; 32]);
let orig = "+1-212-555-0100";
let out = gen.generate(&Category::Phone, orig);
assert!(out.starts_with('+'));
assert_eq!(
out.chars().filter(|c| *c == '-').count(),
orig.chars().filter(|c| *c == '-').count(),
"dashes must be preserved"
);
assert_eq!(out.len(), orig.len(), "phone must preserve length");
}
#[test]
fn hostname_format() {
let gen = HmacGenerator::new([5u8; 32]);
let orig = "db-prod-01.internal";
let out = gen.generate(&Category::Hostname, orig);
assert!(out.ends_with(".internal"), "hostname must preserve suffix");
assert_eq!(out.len(), orig.len(), "hostname must preserve length");
}
#[test]
fn custom_format() {
let gen = HmacGenerator::new([9u8; 32]);
let cat = Category::Custom("api_key".into());
let orig = "sk-abc123-very-long-key";
let out = gen.generate(&cat, orig);
assert!(out.starts_with("__SANITIZED_"));
assert!(out.ends_with("__"));
assert_eq!(out.len(), orig.len(), "custom must preserve length");
}
#[test]
fn custom_format_short() {
let gen = HmacGenerator::new([9u8; 32]);
let cat = Category::Custom("api_key".into());
let orig = "sk-abc123";
let out = gen.generate(&cat, orig);
assert_eq!(
out.len(),
orig.len(),
"custom must preserve length even for short inputs"
);
}
#[test]
fn random_generator_produces_valid_format() {
let gen = RandomGenerator::new();
let orig = "test@example.com";
let out = gen.generate(&Category::Email, orig);
assert!(out.contains('@'));
assert_eq!(
out.len(),
orig.len(),
"random generator must preserve length"
);
}
#[test]
fn from_slice_rejects_bad_length() {
let result = HmacGenerator::from_slice(&[0u8; 16]);
assert!(result.is_err());
}
#[test]
fn credit_card_format() {
let gen = HmacGenerator::new([11u8; 32]);
let orig = "4111-1111-1111-1111";
let out = gen.generate(&Category::CreditCard, orig);
let parts: Vec<&str> = out.split('-').collect();
assert_eq!(parts.len(), 4);
for part in &parts {
assert_eq!(part.len(), 4);
assert!(part.chars().all(|c| c.is_ascii_digit()));
}
assert_eq!(out.len(), orig.len(), "credit card must preserve length");
}
#[test]
fn name_format() {
let gen = HmacGenerator::new([0u8; 32]);
let orig = "John Doe";
let out = gen.generate(&Category::Name, orig);
assert_eq!(out.len(), orig.len(), "name must preserve length");
}
#[test]
fn ipv6_format() {
let gen = HmacGenerator::new([0u8; 32]);
let orig = "fd00:abcd:1234:5678::1";
let out = gen.generate(&Category::IpV6, orig);
assert_eq!(
out.chars().filter(|c| *c == ':').count(),
orig.chars().filter(|c| *c == ':').count(),
"colons must be preserved"
);
assert_eq!(out.len(), orig.len(), "ipv6 must preserve length");
}
#[test]
fn length_preserved_all_categories() {
let gen = HmacGenerator::new([42u8; 32]);
let cases: Vec<(Category, &str)> = vec![
(Category::Email, "alice@corp.com"),
(Category::Name, "John Doe"),
(Category::Phone, "+1-212-555-0100"),
(Category::IpV4, "192.168.1.1"),
(Category::IpV6, "fd00::1"),
(Category::CreditCard, "4111-1111-1111-1111"),
(Category::Ssn, "123-45-6789"),
(Category::Hostname, "db-prod-01.internal"),
(Category::MacAddress, "AA:BB:CC:DD:EE:FF"),
(Category::ContainerId, "a1b2c3d4e5f6"),
(Category::Uuid, "550e8400-e29b-41d4-a716-446655440000"),
(Category::Jwt, "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK"),
(Category::AuthToken, "ghp_abc123secrettoken"),
(Category::FilePath, "/home/jsmith/config.yaml"),
(Category::WindowsSid, "S-1-5-21-3623811015-3361044348"),
(Category::Url, "https://internal.corp.com/api"),
(Category::AwsArn, "arn:aws:iam::123456789012:user/admin"),
(
Category::AzureResourceId,
"/subscriptions/550e8400/resourceGroups/rg-prod",
),
(Category::Custom("key".into()), "some-secret-value-here"),
];
for (cat, orig) in &cases {
let out = gen.generate(cat, orig);
assert_eq!(
out.len(),
orig.len(),
"length mismatch for {:?}: '{}' ({}) -> '{}' ({})",
cat,
orig,
orig.len(),
out,
out.len()
);
}
}
#[test]
fn mac_address_format() {
let gen = HmacGenerator::new([7u8; 32]);
let orig = "AA:BB:CC:DD:EE:FF";
let out = gen.generate(&Category::MacAddress, orig);
assert_eq!(out.len(), orig.len(), "mac must preserve length");
assert_eq!(
out.chars().filter(|c| *c == ':').count(),
5,
"mac must preserve colons"
);
}
#[test]
fn mac_address_dash_format() {
let gen = HmacGenerator::new([7u8; 32]);
let orig = "AA-BB-CC-DD-EE-FF";
let out = gen.generate(&Category::MacAddress, orig);
assert_eq!(out.len(), orig.len());
assert_eq!(out.chars().filter(|c| *c == '-').count(), 5);
}
#[test]
fn uuid_format() {
let gen = HmacGenerator::new([3u8; 32]);
let orig = "550e8400-e29b-41d4-a716-446655440000";
let out = gen.generate(&Category::Uuid, orig);
assert_eq!(out.len(), orig.len(), "uuid must preserve length");
assert_eq!(
out.chars().filter(|c| *c == '-').count(),
4,
"uuid must preserve dashes"
);
}
#[test]
fn container_id_format() {
let gen = HmacGenerator::new([5u8; 32]);
let orig = "a1b2c3d4e5f6";
let out = gen.generate(&Category::ContainerId, orig);
assert_eq!(out.len(), orig.len(), "container id must preserve length");
assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn jwt_format() {
let gen = HmacGenerator::new([11u8; 32]);
let orig = "eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK";
let out = gen.generate(&Category::Jwt, orig);
assert_eq!(out.len(), orig.len(), "jwt must preserve length");
let orig_dots = orig.chars().filter(|c| *c == '.').count();
let out_dots = out.chars().filter(|c| *c == '.').count();
assert_eq!(out_dots, orig_dots, "jwt must preserve dots");
}
#[test]
fn auth_token_format() {
let gen = HmacGenerator::new([9u8; 32]);
let orig = "ghp_abc123secrettoken";
let out = gen.generate(&Category::AuthToken, orig);
assert!(out.starts_with("__SANITIZED_"));
assert!(out.ends_with("__"));
assert_eq!(out.len(), orig.len(), "auth_token must preserve length");
}
#[test]
fn filepath_unix_format() {
let gen = HmacGenerator::new([13u8; 32]);
let orig = "/home/jsmith/config.yaml";
let out = gen.generate(&Category::FilePath, orig);
assert_eq!(out.len(), orig.len(), "filepath must preserve length");
assert_eq!(
std::path::Path::new(&out)
.extension()
.and_then(|e| e.to_str()),
Some("yaml"),
"filepath must preserve extension"
);
assert_eq!(
out.chars().filter(|c| *c == '/').count(),
orig.chars().filter(|c| *c == '/').count(),
"filepath must preserve separators"
);
}
#[test]
fn filepath_windows_format() {
let gen = HmacGenerator::new([13u8; 32]);
let orig = "C:\\Users\\admin\\secrets.txt";
let out = gen.generate(&Category::FilePath, orig);
assert_eq!(out.len(), orig.len(), "filepath must preserve length");
assert_eq!(
std::path::Path::new(&out)
.extension()
.and_then(|e| e.to_str()),
Some("txt"),
"filepath must preserve extension"
);
assert_eq!(
out.chars().filter(|c| *c == '\\').count(),
orig.chars().filter(|c| *c == '\\').count(),
"filepath must preserve backslashes"
);
}
#[test]
fn windows_sid_format() {
let gen = HmacGenerator::new([7u8; 32]);
let orig = "S-1-5-21-3623811015-3361044348-30300820-1013";
let out = gen.generate(&Category::WindowsSid, orig);
assert_eq!(out.len(), orig.len(), "SID must preserve length");
assert!(out.starts_with("S-"), "SID must start with S-");
assert_eq!(
out.chars().filter(|c| *c == '-').count(),
orig.chars().filter(|c| *c == '-').count(),
"SID must preserve dashes"
);
}
#[test]
fn url_format() {
let gen = HmacGenerator::new([5u8; 32]);
let orig = "https://internal.corp.com/api/users?token=abc123";
let out = gen.generate(&Category::Url, orig);
assert_eq!(out.len(), orig.len(), "url must preserve length");
assert!(out.contains("://"));
assert!(out.contains('?'));
assert!(out.contains('='));
}
#[test]
fn aws_arn_format() {
let gen = HmacGenerator::new([3u8; 32]);
let orig = "arn:aws:iam::123456789012:user/admin";
let out = gen.generate(&Category::AwsArn, orig);
assert_eq!(out.len(), orig.len(), "ARN must preserve length");
assert_eq!(
out.chars().filter(|c| *c == ':').count(),
orig.chars().filter(|c| *c == ':').count(),
"ARN must preserve colons"
);
assert!(out.contains('/'), "ARN must preserve slash");
}
#[test]
fn azure_resource_id_format() {
let gen = HmacGenerator::new([11u8; 32]);
let orig = "/subscriptions/550e8400-e29b/resourceGroups/rg-prod/providers/Microsoft.Compute/virtualMachines/vm-01";
let out = gen.generate(&Category::AzureResourceId, orig);
assert_eq!(
out.len(),
orig.len(),
"Azure resource ID must preserve length"
);
assert!(
out.contains("/subscriptions/"),
"must preserve 'subscriptions'"
);
assert!(
out.contains("/resourceGroups/"),
"must preserve 'resourceGroups'"
);
assert!(out.contains("/providers/"), "must preserve 'providers'");
assert!(
out.contains("Microsoft.Compute"),
"must preserve dotted provider name"
);
}
}