1use crate::{CloakError, EntityType, PiiEntity, Result};
4use aes_gcm::aead::Aead;
5use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
6use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
7use base64::Engine;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum MaskStrategy {
27 #[default]
29 Redact,
30 PartialMask {
32 reveal_prefix: usize,
34 reveal_suffix: usize,
36 mask_char: char,
38 },
39 Hash {
41 salt: Option<String>,
43 },
44 Replace,
46 Encrypt {
48 key: String,
50 },
51 Custom(String),
53}
54
55impl MaskStrategy {
56 #[must_use]
58 pub fn replacement(&self, finding: &PiiEntity) -> String {
59 match self.try_replacement(finding) {
60 Ok(replacement) => replacement,
61 Err(_) => finding.entity_type.redaction_tag(),
62 }
63 }
64
65 pub fn try_replacement(&self, finding: &PiiEntity) -> Result<String> {
67 match self {
68 Self::Redact => Ok(finding.entity_type.redaction_tag()),
69 Self::PartialMask {
70 reveal_prefix,
71 reveal_suffix,
72 mask_char,
73 } => Ok(partial_mask(
74 finding,
75 *reveal_prefix,
76 *reveal_suffix,
77 *mask_char,
78 )),
79 Self::Hash { salt } => {
80 if finding.entity_type == EntityType::UserPath {
81 Ok(hash_user_path(&finding.text, salt.as_deref()))
82 } else {
83 Ok(hash_mask(finding, salt.as_deref(), DEFAULT_HASH_LENGTH))
84 }
85 }
86 Self::Replace => Ok(replace_mask(finding)),
87 Self::Encrypt { key } => encrypt_mask(finding, key),
88 Self::Custom(replacement) => Ok(replacement.clone()),
89 }
90 }
91}
92
93const DEFAULT_HASH_LENGTH: usize = 16;
94const MIN_HASH_LENGTH: usize = 8;
95const MAX_HASH_LENGTH: usize = 64;
96const NONCE_LENGTH: usize = 12;
97
98pub fn apply_mask(text: &str, findings: &[PiiEntity], strategy: &MaskStrategy) -> Result<String> {
120 let mut findings = deduplicate(findings);
121 findings.sort_by_key(|finding| std::cmp::Reverse(finding.span.start));
122
123 let mut result = text.to_string();
124 for finding in findings {
125 validate_span(text, &finding)?;
126 result.replace_range(
127 finding.span.start..finding.span.end,
128 &strategy.try_replacement(&finding)?,
129 );
130 }
131 Ok(result)
132}
133
134pub fn decrypt_masked_value(value: &str, key: &str) -> Result<String> {
154 let Some(encoded) = value
155 .strip_prefix("ENC[")
156 .and_then(|value| value.strip_suffix(']'))
157 else {
158 return Err(CloakError::EncryptionError(
159 "encrypted value must use ENC[...] format".to_string(),
160 ));
161 };
162
163 let bytes = BASE64_STANDARD
164 .decode(encoded)
165 .map_err(|error| CloakError::EncryptionError(error.to_string()))?;
166 if bytes.len() <= NONCE_LENGTH {
167 return Err(CloakError::EncryptionError(
168 "encrypted payload is too short".to_string(),
169 ));
170 }
171
172 let key = parse_hex_key(key)?;
173 let cipher = Aes256Gcm::new_from_slice(&key)
174 .map_err(|error| CloakError::EncryptionError(error.to_string()))?;
175 let nonce = Nonce::from_slice(&bytes[..NONCE_LENGTH]);
176 let plaintext = cipher
177 .decrypt(nonce, &bytes[NONCE_LENGTH..])
178 .map_err(|error| CloakError::EncryptionError(error.to_string()))?;
179
180 String::from_utf8(plaintext).map_err(|error| CloakError::EncryptionError(error.to_string()))
181}
182
183#[must_use]
185pub fn deduplicate(findings: &[PiiEntity]) -> Vec<PiiEntity> {
186 let mut sorted = findings.to_vec();
187 sorted.sort_by_key(|finding| (finding.span.start, std::cmp::Reverse(finding.span.end)));
188
189 let mut keep: Vec<PiiEntity> = Vec::with_capacity(sorted.len());
190 for finding in sorted {
191 if let Some(last) = keep.last_mut() {
192 if finding.span.overlaps(last.span) {
193 let merged = merge_overlapping(last, &finding);
194 *last = merged;
195 continue;
196 }
197 }
198 keep.push(finding);
199 }
200
201 keep
202}
203
204fn merge_overlapping(left: &PiiEntity, right: &PiiEntity) -> PiiEntity {
205 if left.span.start == right.span.start && left.span.end == right.span.end {
206 return if right.confidence > left.confidence {
207 right.clone()
208 } else {
209 left.clone()
210 };
211 }
212
213 if right.span.len() > left.span.len()
214 || (right.span.len() == left.span.len() && right.confidence > left.confidence)
215 {
216 right.clone()
217 } else {
218 left.clone()
219 }
220}
221
222fn validate_span(text: &str, finding: &PiiEntity) -> Result<()> {
223 let start = finding.span.start;
224 let end = finding.span.end;
225 if start <= end
226 && end <= text.len()
227 && text.is_char_boundary(start)
228 && text.is_char_boundary(end)
229 {
230 Ok(())
231 } else {
232 Err(CloakError::InvalidSpan {
233 start,
234 end,
235 len: text.len(),
236 })
237 }
238}
239
240fn partial_mask(
241 finding: &PiiEntity,
242 reveal_prefix: usize,
243 reveal_suffix: usize,
244 mask_char: char,
245) -> String {
246 match finding.entity_type {
247 EntityType::Email => mask_email(&finding.text, mask_char),
248 EntityType::CreditCard => mask_preserving_separators(&finding.text, 0, 4, mask_char),
249 EntityType::PhoneNumber => mask_phone(&finding.text, mask_char),
250 EntityType::Ssn => mask_preserving_separators(&finding.text, 0, 4, mask_char),
251 EntityType::Iban => mask_preserving_separators(&finding.text, 2, 4, mask_char),
252 EntityType::IpAddress => mask_ip(&finding.text),
253 EntityType::Hostname => mask_hostname(&finding.text, mask_char),
254 EntityType::UserPath => mask_user_path(&finding.text, mask_char),
255 EntityType::Bsn => mask_preserving_separators(&finding.text, 0, 3, mask_char),
256 EntityType::Aadhaar => mask_preserving_separators(&finding.text, 0, 4, mask_char),
257 EntityType::Jwt => mask_jwt(&finding.text),
258 EntityType::ApiKey | EntityType::AwsAccessKey => {
259 mask_generic(&finding.text, 4, 4, mask_char)
260 }
261 _ => mask_generic(&finding.text, reveal_prefix, reveal_suffix, mask_char),
262 }
263}
264
265fn hash_mask(finding: &PiiEntity, salt: Option<&str>, length: usize) -> String {
266 hash_value(&finding.text, salt, length)
267}
268
269fn hash_value(value: &str, salt: Option<&str>, length: usize) -> String {
270 let length = length.clamp(MIN_HASH_LENGTH, MAX_HASH_LENGTH);
271 let mut hasher = Sha256::new();
272 if let Some(salt) = salt {
273 hasher.update(salt.as_bytes());
274 }
275 hasher.update(value.as_bytes());
276 let digest = hasher.finalize();
277 let hex = to_hex(&digest);
278 format!("HASH:{}", &hex[..length])
279}
280
281fn hash_user_path(path: &str, salt: Option<&str>) -> String {
282 let Some((range, username)) = user_path_username_range(path) else {
283 return hash_value(path, salt, DEFAULT_HASH_LENGTH);
284 };
285 replace_range_owned(
286 path,
287 range,
288 &hash_value(username, salt, DEFAULT_HASH_LENGTH),
289 )
290}
291
292fn replace_mask(finding: &PiiEntity) -> String {
293 let seed = deterministic_seed(&finding.text);
294 match finding.entity_type {
295 EntityType::Email => format!("user{}@example.test", seed % 10_000),
296 EntityType::PhoneNumber => format!("+1 555 010 {:04}", seed % 10_000),
297 EntityType::CreditCard => fake_card(seed),
298 EntityType::Ssn => format!(
299 "9{:02}-{:02}-{:04}",
300 seed % 100,
301 (seed / 100) % 100,
302 (seed / 10_000) % 10_000
303 ),
304 EntityType::Iban => fake_iban(&finding.text),
305 EntityType::Bsn => format!("99999{:04}", seed % 10_000),
306 EntityType::Aadhaar => format!("9999 9999 {:04}", seed % 10_000),
307 EntityType::IpAddress => "192.0.2.1".to_string(),
308 EntityType::Url => "https://example.test/redacted".to_string(),
309 EntityType::Jwt => "eyJhbGciOiJIUzI1NiJ9.[REPLACED].signature".to_string(),
310 EntityType::ApiKey => format!("test_key_{:016x}", seed),
311 EntityType::AwsAccessKey => "AKIAIOSFODNN7EXAMPLE".to_string(),
312 EntityType::CryptoAddress => "0x0000000000000000000000000000000000000000".to_string(),
313 EntityType::MacAddress => "02:00:00:00:00:01".to_string(),
314 EntityType::Hostname => "host.example.test".to_string(),
315 EntityType::UserPath => "/home/user/redacted".to_string(),
316 EntityType::PersonName => format!("Alex Example {}", seed % 10_000),
317 EntityType::PhysicalAddress => format!("{} Example St", 100 + (seed % 900)),
318 _ => finding
319 .entity_type
320 .redaction_tag()
321 .replace('[', "[REPLACED_"),
322 }
323}
324
325fn fake_card(seed: u64) -> String {
326 const CARDS: &[&str] = &[
327 "4111111111111111",
328 "5555555555554444",
329 "378282246310005",
330 "6011111111111117",
331 ];
332 CARDS[(seed as usize) % CARDS.len()].to_string()
333}
334
335fn fake_iban(original: &str) -> String {
336 match original.get(..2).map(str::to_ascii_uppercase).as_deref() {
337 Some("DE") => "DE89 3704 0044 0532 0130 00".to_string(),
338 Some("GB") => "GB29 NWBK 6016 1331 9268 19".to_string(),
339 Some("FR") => "FR14 2004 1010 0505 0001 3M02 606".to_string(),
340 _ => "NL91 ABNA 0417 1643 00".to_string(),
341 }
342}
343
344fn encrypt_mask(finding: &PiiEntity, key: &str) -> Result<String> {
345 let key = parse_hex_key(key)?;
346 let cipher = Aes256Gcm::new_from_slice(&key)
347 .map_err(|error| CloakError::EncryptionError(error.to_string()))?;
348 let nonce_bytes = derive_nonce(finding);
349 let nonce = Nonce::from_slice(&nonce_bytes);
350 let ciphertext = cipher
351 .encrypt(nonce, finding.text.as_bytes())
352 .map_err(|error| CloakError::EncryptionError(error.to_string()))?;
353
354 let mut payload = Vec::with_capacity(NONCE_LENGTH + ciphertext.len());
355 payload.extend_from_slice(&nonce_bytes);
356 payload.extend_from_slice(&ciphertext);
357 Ok(format!("ENC[{}]", BASE64_STANDARD.encode(payload)))
358}
359
360fn parse_hex_key(key: &str) -> Result<[u8; 32]> {
361 if key.len() != 64 {
362 return Err(CloakError::EncryptionError(
363 "encryption key must be 32 bytes encoded as 64 hex characters".to_string(),
364 ));
365 }
366
367 let mut bytes = [0u8; 32];
368 for (index, chunk) in key.as_bytes().chunks_exact(2).enumerate() {
369 let high = hex_nibble(chunk[0])?;
370 let low = hex_nibble(chunk[1])?;
371 bytes[index] = (high << 4) | low;
372 }
373 Ok(bytes)
374}
375
376fn hex_nibble(byte: u8) -> Result<u8> {
377 match byte {
378 b'0'..=b'9' => Ok(byte - b'0'),
379 b'a'..=b'f' => Ok(byte - b'a' + 10),
380 b'A'..=b'F' => Ok(byte - b'A' + 10),
381 _ => Err(CloakError::EncryptionError(
382 "encryption key must contain only hex characters".to_string(),
383 )),
384 }
385}
386
387fn derive_nonce(finding: &PiiEntity) -> [u8; NONCE_LENGTH] {
388 let mut hasher = Sha256::new();
389 hasher.update(finding.entity_type.redaction_tag().as_bytes());
390 hasher.update(finding.span.start.to_le_bytes());
391 hasher.update(finding.span.end.to_le_bytes());
392 hasher.update(finding.recognizer_id.as_bytes());
393 hasher.update(finding.text.as_bytes());
394 let digest = hasher.finalize();
395 let mut nonce = [0u8; NONCE_LENGTH];
396 nonce.copy_from_slice(&digest[..NONCE_LENGTH]);
397 nonce
398}
399
400fn deterministic_seed(value: &str) -> u64 {
401 let digest = Sha256::digest(value.as_bytes());
402 let mut bytes = [0u8; 8];
403 bytes.copy_from_slice(&digest[..8]);
404 u64::from_le_bytes(bytes)
405}
406
407fn to_hex(bytes: &[u8]) -> String {
408 const HEX: &[u8; 16] = b"0123456789abcdef";
409 let mut out = String::with_capacity(bytes.len() * 2);
410 for byte in bytes {
411 out.push(HEX[(byte >> 4) as usize] as char);
412 out.push(HEX[(byte & 0x0f) as usize] as char);
413 }
414 out
415}
416
417fn mask_email(email: &str, mask_char: char) -> String {
418 let Some((local, domain)) = email.split_once('@') else {
419 return mask_generic(email, 0, 0, mask_char);
420 };
421
422 if local.is_empty() {
423 return format!("{mask_char}@{domain}");
424 }
425
426 let mut chars = local.chars();
427 let Some(first) = chars.next() else {
428 return format!("{mask_char}@{domain}");
429 };
430 let masked_count = chars.count();
431 format!(
432 "{first}{}@{domain}",
433 mask_char.to_string().repeat(masked_count)
434 )
435}
436
437fn mask_preserving_separators(
438 value: &str,
439 reveal_prefix: usize,
440 reveal_suffix: usize,
441 mask_char: char,
442) -> String {
443 let sensitive_count = value.chars().filter(|c| c.is_ascii_alphanumeric()).count();
444 let mut sensitive_index = 0usize;
445
446 value
447 .chars()
448 .map(|c| {
449 if c.is_ascii_alphanumeric() {
450 sensitive_index += 1;
451 if sensitive_index <= reveal_prefix
452 || sensitive_index > sensitive_count.saturating_sub(reveal_suffix)
453 {
454 c
455 } else {
456 mask_char
457 }
458 } else {
459 c
460 }
461 })
462 .collect()
463}
464
465fn mask_phone(phone: &str, mask_char: char) -> String {
466 let reveal_prefix = usize::from(phone.starts_with('+')) * 2;
467 mask_preserving_separators(phone, reveal_prefix, 2, mask_char)
468}
469
470fn mask_ip(ip: &str) -> String {
471 let parts: Vec<&str> = ip.split('.').collect();
472 if parts.len() == 4 {
473 format!("{}.{}.*.*", parts[0], parts[1])
474 } else {
475 "*".repeat(ip.chars().count())
476 }
477}
478
479fn mask_hostname(hostname: &str, mask_char: char) -> String {
480 let labels: Vec<&str> = hostname.split('.').collect();
481 if labels.len() < 2 {
482 return mask_generic(hostname, 0, 0, mask_char);
483 }
484
485 let last_index = labels.len() - 1;
486 labels
487 .iter()
488 .enumerate()
489 .map(|(index, label)| {
490 if index == last_index || is_preserved_hostname_label(label) {
491 (*label).to_string()
492 } else if index == 0 {
493 mask_hostname_first_label(label, mask_char)
494 } else {
495 mask_preserving_label_separators(label, mask_char)
496 }
497 })
498 .collect::<Vec<_>>()
499 .join(".")
500}
501
502fn is_preserved_hostname_label(label: &str) -> bool {
503 matches!(
504 label.to_ascii_lowercase().as_str(),
505 "internal" | "local" | "lan" | "corp" | "private" | "intranet"
506 )
507}
508
509fn mask_hostname_first_label(label: &str, mask_char: char) -> String {
510 if let Some((prefix, rest)) = label.split_once('-') {
511 if !prefix.is_empty() && !rest.is_empty() {
512 return format!(
513 "{prefix}-{}",
514 mask_preserving_label_separators(rest, mask_char)
515 );
516 }
517 }
518 mask_generic(label, 2.min(label.chars().count()), 0, mask_char)
519}
520
521fn mask_preserving_label_separators(label: &str, mask_char: char) -> String {
522 label
523 .chars()
524 .map(|c| {
525 if c.is_ascii_alphanumeric() {
526 mask_char
527 } else {
528 c
529 }
530 })
531 .collect()
532}
533
534fn mask_user_path(path: &str, mask_char: char) -> String {
535 let Some((range, username)) = user_path_username_range(path) else {
536 return mask_generic(path, 0, 0, mask_char);
537 };
538 replace_range_owned(
539 path,
540 range,
541 &mask_char.to_string().repeat(username.chars().count()),
542 )
543}
544
545fn user_path_username_range(path: &str) -> Option<(std::ops::Range<usize>, &str)> {
546 if let Some(rest) = path.strip_prefix("/home/") {
547 return username_range_after_prefix(path, "/home/".len(), rest, '/');
548 }
549 if let Some(rest) = path.strip_prefix("/Users/") {
550 return username_range_after_prefix(path, "/Users/".len(), rest, '/');
551 }
552 let lower = path.to_ascii_lowercase();
553 if let Some(index) = lower.find(r"\users\") {
554 let prefix_end = index + r"\Users\".len();
555 return username_range_after_prefix(path, prefix_end, &path[prefix_end..], '\\');
556 }
557 if path == "/root" || path.starts_with("/root/") {
558 return Some((1..5, &path[1..5]));
559 }
560 None
561}
562
563fn username_range_after_prefix<'a>(
564 path: &'a str,
565 prefix_end: usize,
566 rest: &'a str,
567 separator: char,
568) -> Option<(std::ops::Range<usize>, &'a str)> {
569 let username_len = rest.find(separator).unwrap_or(rest.len());
570 (username_len > 0).then(|| {
571 let start = prefix_end;
572 let end = prefix_end + username_len;
573 (start..end, &path[start..end])
574 })
575}
576
577fn replace_range_owned(value: &str, range: std::ops::Range<usize>, replacement: &str) -> String {
578 let mut result = value.to_string();
579 result.replace_range(range, replacement);
580 result
581}
582
583fn mask_jwt(jwt: &str) -> String {
584 let prefix: String = jwt.chars().take(10).collect();
585 format!("{prefix}[TRUNCATED]")
586}
587
588fn mask_generic(
589 value: &str,
590 reveal_prefix: usize,
591 reveal_suffix: usize,
592 mask_char: char,
593) -> String {
594 let chars: Vec<char> = value.chars().collect();
595 let len = chars.len();
596 let prefix = reveal_prefix.min(len);
597 let suffix = reveal_suffix.min(len.saturating_sub(prefix));
598 let mask_len = len.saturating_sub(prefix + suffix);
599
600 let mut result = String::with_capacity(value.len());
601 result.extend(&chars[..prefix]);
602 result.extend(std::iter::repeat(mask_char).take(mask_len));
603 result.extend(&chars[len - suffix..]);
604 result
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use crate::{Confidence, Span};
611
612 fn finding(entity_type: EntityType, start: usize, end: usize, text: &str) -> PiiEntity {
613 PiiEntity {
614 entity_type,
615 span: Span::new(start, end),
616 text: text.to_string(),
617 confidence: Confidence::new(0.9).unwrap(),
618 recognizer_id: "test_v1".to_string(),
619 }
620 }
621
622 #[test]
623 fn test_apply_mask_redact_replaces_pii_with_tag() {
624 let text = "Email user@example.com now";
625 let findings = [finding(EntityType::Email, 6, 22, "user@example.com")];
626 let masked = apply_mask(text, &findings, &MaskStrategy::Redact).unwrap();
627 assert_eq!(masked, "Email [EMAIL] now");
628 }
629
630 #[test]
631 fn test_apply_mask_uses_reverse_span_order() {
632 let text = "a@b.co and c@d.co";
633 let findings = [
634 finding(EntityType::Email, 0, 6, "a@b.co"),
635 finding(EntityType::Email, 11, 17, "c@d.co"),
636 ];
637 let masked = apply_mask(text, &findings, &MaskStrategy::Redact).unwrap();
638 assert_eq!(masked, "[EMAIL] and [EMAIL]");
639 }
640
641 #[test]
642 fn test_partial_mask_email_preserves_domain() {
643 let item = finding(EntityType::Email, 0, 16, "john@example.com");
644 let masked = MaskStrategy::PartialMask {
645 reveal_prefix: 0,
646 reveal_suffix: 0,
647 mask_char: '*',
648 }
649 .replacement(&item);
650 assert_eq!(masked, "j***@example.com");
651 }
652
653 #[test]
654 fn test_partial_mask_credit_card_preserves_separators() {
655 let item = finding(EntityType::CreditCard, 0, 19, "4111-1111-1111-1111");
656 let masked = MaskStrategy::PartialMask {
657 reveal_prefix: 0,
658 reveal_suffix: 4,
659 mask_char: '*',
660 }
661 .replacement(&item);
662 assert_eq!(masked, "****-****-****-1111");
663 }
664
665 #[test]
666 fn test_partial_mask_hostname_preserves_structure() {
667 let item = finding(
668 EntityType::Hostname,
669 0,
670 31,
671 "db-prod-01.internal.company.com",
672 );
673 let masked = MaskStrategy::PartialMask {
674 reveal_prefix: 0,
675 reveal_suffix: 0,
676 mask_char: '*',
677 }
678 .replacement(&item);
679 assert_eq!(masked, "db-****-**.internal.*******.com");
680 }
681
682 #[test]
683 fn test_partial_mask_user_path_masks_username_only() {
684 let item = finding(EntityType::UserPath, 0, 24, "/home/kadir/projects/app");
685 let masked = MaskStrategy::PartialMask {
686 reveal_prefix: 0,
687 reveal_suffix: 0,
688 mask_char: '*',
689 }
690 .replacement(&item);
691 assert_eq!(masked, "/home/*****/projects/app");
692 }
693
694 #[test]
695 fn test_partial_mask_windows_user_path_masks_username_only() {
696 let item = finding(EntityType::UserPath, 0, 25, r"C:\Users\john.doe\Desktop");
697 let masked = MaskStrategy::PartialMask {
698 reveal_prefix: 0,
699 reveal_suffix: 0,
700 mask_char: '*',
701 }
702 .replacement(&item);
703 assert_eq!(masked, r"C:\Users\********\Desktop");
704 }
705
706 #[test]
707 fn test_apply_mask_invalid_span_returns_error() {
708 let text = "short";
709 let findings = [finding(EntityType::Email, 0, 99, "short")];
710 assert!(apply_mask(text, &findings, &MaskStrategy::Redact).is_err());
711 }
712
713 #[test]
714 fn test_hash_mask_is_deterministic_without_salt() {
715 let item = finding(EntityType::Email, 0, 16, "user@example.com");
716 let strategy = MaskStrategy::Hash { salt: None };
717 assert_eq!(strategy.replacement(&item), strategy.replacement(&item));
718 }
719
720 #[test]
721 fn test_hash_mask_uses_expected_prefix_and_default_length() {
722 let item = finding(EntityType::Email, 0, 16, "user@example.com");
723 let replacement = MaskStrategy::Hash { salt: None }.replacement(&item);
724 assert!(replacement.starts_with("HASH:"));
725 assert_eq!(replacement.len(), "HASH:".len() + DEFAULT_HASH_LENGTH);
726 }
727
728 #[test]
729 fn test_hash_mask_salt_changes_output() {
730 let item = finding(EntityType::Email, 0, 16, "user@example.com");
731 let without_salt = MaskStrategy::Hash { salt: None }.replacement(&item);
732 let with_salt = MaskStrategy::Hash {
733 salt: Some("prod".to_string()),
734 }
735 .replacement(&item);
736 assert_ne!(without_salt, with_salt);
737 }
738
739 #[test]
740 fn test_hash_mask_user_path_hashes_username_only() {
741 let item = finding(EntityType::UserPath, 0, 24, "/home/kadir/projects/app");
742 let replacement = MaskStrategy::Hash { salt: None }.replacement(&item);
743 assert!(replacement.starts_with("/home/HASH:"));
744 assert!(replacement.ends_with("/projects/app"));
745 }
746
747 #[test]
748 fn test_hash_mask_length_is_clamped_to_bounds() {
749 let item = finding(EntityType::Email, 0, 16, "user@example.com");
750 assert_eq!(
751 hash_mask(&item, None, 2).len(),
752 "HASH:".len() + MIN_HASH_LENGTH
753 );
754 assert_eq!(
755 hash_mask(&item, None, 128).len(),
756 "HASH:".len() + MAX_HASH_LENGTH
757 );
758 }
759
760 #[test]
761 fn test_replace_email_uses_example_test_domain() {
762 let item = finding(EntityType::Email, 0, 16, "user@example.com");
763 let replacement = MaskStrategy::Replace.replacement(&item);
764 assert!(replacement.ends_with("@example.test"));
765 }
766
767 #[test]
768 fn test_replace_is_deterministic_for_same_input() {
769 let item = finding(EntityType::Email, 0, 16, "user@example.com");
770 assert_eq!(
771 MaskStrategy::Replace.replacement(&item),
772 MaskStrategy::Replace.replacement(&item)
773 );
774 }
775
776 #[test]
777 fn test_replace_credit_card_uses_luhn_valid_test_number() {
778 let item = finding(EntityType::CreditCard, 0, 16, "4111111111111111");
779 let replacement = MaskStrategy::Replace.replacement(&item);
780 assert!(luhn_valid_digits(&replacement));
781 }
782
783 #[test]
784 fn test_replace_ssn_uses_reserved_area_range() {
785 let item = finding(EntityType::Ssn, 0, 11, "123-45-6789");
786 let replacement = MaskStrategy::Replace.replacement(&item);
787 assert!(replacement.starts_with('9'));
788 }
789
790 #[test]
791 fn test_encrypt_mask_round_trips() {
792 let key = test_key();
793 let item = finding(EntityType::Email, 0, 16, "user@example.com");
794 let encrypted = MaskStrategy::Encrypt { key: key.clone() }
795 .try_replacement(&item)
796 .unwrap();
797 assert!(encrypted.starts_with("ENC["));
798 assert_eq!(
799 decrypt_masked_value(&encrypted, &key).unwrap(),
800 "user@example.com"
801 );
802 }
803
804 #[test]
805 fn test_encrypt_mask_is_deterministic_for_same_finding() {
806 let key = test_key();
807 let item = finding(EntityType::Email, 0, 16, "user@example.com");
808 let strategy = MaskStrategy::Encrypt { key };
809 assert_eq!(
810 strategy.try_replacement(&item).unwrap(),
811 strategy.try_replacement(&item).unwrap()
812 );
813 }
814
815 #[test]
816 fn test_encrypt_mask_invalid_key_returns_error() {
817 let item = finding(EntityType::Email, 0, 16, "user@example.com");
818 assert!(MaskStrategy::Encrypt {
819 key: "short".to_string()
820 }
821 .try_replacement(&item)
822 .is_err());
823 }
824
825 #[test]
826 fn test_decrypt_masked_value_wrong_key_fails() {
827 let item = finding(EntityType::Email, 0, 16, "user@example.com");
828 let encrypted = MaskStrategy::Encrypt { key: test_key() }
829 .try_replacement(&item)
830 .unwrap();
831 assert!(decrypt_masked_value(
832 &encrypted,
833 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
834 )
835 .is_err());
836 }
837
838 #[test]
839 fn test_decrypt_masked_value_tampered_ciphertext_fails() {
840 let item = finding(EntityType::Email, 0, 16, "user@example.com");
841 let encrypted = MaskStrategy::Encrypt { key: test_key() }
842 .try_replacement(&item)
843 .unwrap();
844 let tampered = encrypted.replace("A", "B");
845 assert!(decrypt_masked_value(&tampered, &test_key()).is_err());
846 }
847
848 fn test_key() -> String {
849 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".to_string()
850 }
851
852 fn luhn_valid_digits(value: &str) -> bool {
853 let digits: Vec<u32> = value.chars().filter_map(|c| c.to_digit(10)).collect();
854 let mut sum = 0u32;
855 let mut double = false;
856 for digit in digits.iter().rev() {
857 let mut value = *digit;
858 if double {
859 value *= 2;
860 if value > 9 {
861 value -= 9;
862 }
863 }
864 sum += value;
865 double = !double;
866 }
867 sum % 10 == 0
868 }
869}