Skip to main content

cloakrs_core/
masker.rs

1//! Masking strategies for detected PII.
2
3use 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/// How detected PII should be masked.
12///
13/// # Examples
14///
15/// ```
16/// use cloakrs_core::MaskStrategy;
17///
18/// let strategy = MaskStrategy::PartialMask {
19///     reveal_prefix: 1,
20///     reveal_suffix: 4,
21///     mask_char: '*',
22/// };
23/// assert!(matches!(strategy, MaskStrategy::PartialMask { .. }));
24/// ```
25#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum MaskStrategy {
27    /// Replace with a typed placeholder such as `[EMAIL]` or `[SSN]`.
28    #[default]
29    Redact,
30    /// Preserve selected prefix and suffix characters while masking the middle.
31    PartialMask {
32        /// Number of characters to reveal at the start.
33        reveal_prefix: usize,
34        /// Number of characters to reveal at the end.
35        reveal_suffix: usize,
36        /// Character used for masked positions.
37        mask_char: char,
38    },
39    /// Deterministic SHA-256 hash.
40    Hash {
41        /// Optional salt.
42        salt: Option<String>,
43    },
44    /// Replace with deterministic fake-but-safe data.
45    Replace,
46    /// AES-256-GCM encryption.
47    Encrypt {
48        /// Encryption key.
49        key: String,
50    },
51    /// Replace every finding with this exact string.
52    Custom(String),
53}
54
55impl MaskStrategy {
56    /// Returns the replacement text for a finding.
57    #[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    /// Returns the replacement text for a finding, propagating fallible strategies.
66    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
98/// Applies a masking strategy to text using the supplied findings.
99///
100/// Findings are deduplicated and then processed in descending span order so
101/// earlier byte offsets remain valid while the string is modified.
102///
103/// # Examples
104///
105/// ```
106/// use cloakrs_core::{apply_mask, Confidence, EntityType, MaskStrategy, PiiEntity, Span};
107///
108/// let finding = PiiEntity {
109///     entity_type: EntityType::Email,
110///     span: Span::new(8, 24),
111///     text: "user@example.com".to_string(),
112///     confidence: Confidence::new(0.95).unwrap(),
113///     recognizer_id: "email_regex_v1".to_string(),
114/// };
115///
116/// let masked = apply_mask("Contact user@example.com", &[finding], &MaskStrategy::Redact).unwrap();
117/// assert_eq!(masked, "Contact [EMAIL]");
118/// ```
119pub 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
134/// Decrypts a value produced by [`MaskStrategy::Encrypt`].
135///
136/// # Examples
137///
138/// ```
139/// use cloakrs_core::{apply_mask, decrypt_masked_value, Confidence, EntityType, MaskStrategy, PiiEntity, Span};
140///
141/// let key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
142/// let finding = PiiEntity {
143///     entity_type: EntityType::Email,
144///     span: Span::new(0, 16),
145///     text: "user@example.com".to_string(),
146///     confidence: Confidence::new(0.95).unwrap(),
147///     recognizer_id: "email_regex_v1".to_string(),
148/// };
149///
150/// let encrypted = apply_mask("user@example.com", &[finding], &MaskStrategy::Encrypt { key: key.to_string() }).unwrap();
151/// assert_eq!(decrypt_masked_value(&encrypted, key).unwrap(), "user@example.com");
152/// ```
153pub 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/// Deduplicates overlapping findings.
184#[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}