1use std::collections::{BTreeMap, HashMap};
46use std::fmt;
47
48use regex::Regex;
49
50use crate::document::{InlineRun, Node, Table};
51use crate::tree::{ItemTree, TreeKind};
52use crate::DoclingDocument;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
59pub enum PiiKind {
60 Email,
61 CreditCard,
62 Iban,
63 NationalId,
64 IpAddress,
65 UrlCredentials,
66 Phone,
67 Person,
68 Organization,
69 Location,
70 Address,
71 Custom,
73}
74
75impl PiiKind {
76 pub const ALL: [PiiKind; 12] = [
78 PiiKind::Email,
79 PiiKind::CreditCard,
80 PiiKind::Iban,
81 PiiKind::NationalId,
82 PiiKind::IpAddress,
83 PiiKind::UrlCredentials,
84 PiiKind::Phone,
85 PiiKind::Person,
86 PiiKind::Organization,
87 PiiKind::Location,
88 PiiKind::Address,
89 PiiKind::Custom,
90 ];
91
92 pub const PATTERN: [PiiKind; 7] = [
94 PiiKind::Email,
95 PiiKind::CreditCard,
96 PiiKind::Iban,
97 PiiKind::NationalId,
98 PiiKind::IpAddress,
99 PiiKind::UrlCredentials,
100 PiiKind::Phone,
101 ];
102
103 pub const NER: [PiiKind; 4] = [
105 PiiKind::Person,
106 PiiKind::Organization,
107 PiiKind::Location,
108 PiiKind::Address,
109 ];
110
111 pub fn label(self) -> &'static str {
113 match self {
114 PiiKind::Email => "EMAIL",
115 PiiKind::Phone => "PHONE",
116 PiiKind::CreditCard => "CREDIT_CARD",
117 PiiKind::Iban => "IBAN",
118 PiiKind::IpAddress => "IP",
119 PiiKind::UrlCredentials => "CREDENTIALS",
120 PiiKind::NationalId => "NATIONAL_ID",
121 PiiKind::Person => "PERSON",
122 PiiKind::Organization => "ORG",
123 PiiKind::Location => "LOCATION",
124 PiiKind::Address => "ADDRESS",
125 PiiKind::Custom => "REDACTED",
126 }
127 }
128
129 pub fn name(self) -> &'static str {
131 match self {
132 PiiKind::Email => "email",
133 PiiKind::Phone => "phone",
134 PiiKind::CreditCard => "credit_card",
135 PiiKind::Iban => "iban",
136 PiiKind::IpAddress => "ip_address",
137 PiiKind::UrlCredentials => "url_credentials",
138 PiiKind::NationalId => "national_id",
139 PiiKind::Person => "person",
140 PiiKind::Organization => "organization",
141 PiiKind::Location => "location",
142 PiiKind::Address => "address",
143 PiiKind::Custom => "custom",
144 }
145 }
146
147 pub fn parse(s: &str) -> Option<Self> {
150 let s = s.trim().to_ascii_lowercase().replace('-', "_");
151 match s.as_str() {
152 "ip" => return Some(PiiKind::IpAddress),
153 "org" => return Some(PiiKind::Organization),
154 "credentials" | "url_credential" => return Some(PiiKind::UrlCredentials),
155 _ => {}
156 }
157 PiiKind::ALL.into_iter().find(|k| k.name() == s)
158 }
159
160 pub const ACCEPTED: &'static str = "email, phone, credit_card, iban, ip_address, \
162 url_credentials, national_id, person, organization, location, address, custom";
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
167pub enum Replacement {
168 #[default]
170 Label,
171 Pseudonym,
176 Fixed(String),
178}
179
180impl Replacement {
181 pub fn parse(s: &str) -> Option<Self> {
183 let t = s.trim();
184 if t.eq_ignore_ascii_case("label") {
185 return Some(Replacement::Label);
186 }
187 if t.eq_ignore_ascii_case("pseudonym") {
188 return Some(Replacement::Pseudonym);
189 }
190 let lower = t.to_ascii_lowercase();
191 if let Some(rest) = lower.strip_prefix("fixed:") {
192 let text = &t[t.len() - rest.len()..];
194 return Some(Replacement::Fixed(text.to_string()));
195 }
196 None
197 }
198
199 pub const ACCEPTED: &'static str = "label, pseudonym, fixed:<text>";
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum ImageRedaction {
207 #[default]
210 Drop,
211 BoxOut,
216 Keep,
218}
219
220impl ImageRedaction {
221 pub fn parse(s: &str) -> Option<Self> {
223 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
224 "drop" => Some(ImageRedaction::Drop),
225 "box_out" | "boxout" | "box" => Some(ImageRedaction::BoxOut),
226 "keep" => Some(ImageRedaction::Keep),
227 _ => None,
228 }
229 }
230
231 pub const ACCEPTED: &'static str = "drop, box_out, keep";
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct CustomPattern {
240 pub name: String,
241 pub regex: String,
242}
243
244impl CustomPattern {
245 pub fn parse(s: &str) -> Option<Self> {
248 let s = s.trim();
249 if s.is_empty() {
250 return None;
251 }
252 let (name, regex) = match s.split_once('=') {
253 Some((n, r))
256 if !n.is_empty()
257 && n.chars()
258 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') =>
259 {
260 (n.to_string(), r.to_string())
261 }
262 _ => ("custom".to_string(), s.to_string()),
263 };
264 Some(CustomPattern { name, regex })
265 }
266}
267
268#[derive(Debug, Clone, PartialEq)]
271pub struct RedactionOptions {
272 pub kinds: Vec<PiiKind>,
275 pub replacement: Replacement,
276 pub custom_patterns: Vec<CustomPattern>,
278 pub deny_terms: Vec<String>,
281 pub allow_terms: Vec<String>,
284 pub allow_patterns: Vec<String>,
289 pub ner_min_score: f32,
293 pub images: ImageRedaction,
294 pub return_mapping: bool,
298}
299
300impl Default for RedactionOptions {
301 fn default() -> Self {
302 Self {
303 kinds: Vec::new(),
304 replacement: Replacement::Label,
305 custom_patterns: Vec::new(),
306 deny_terms: Vec::new(),
307 allow_terms: Vec::new(),
308 allow_patterns: Vec::new(),
309 ner_min_score: 0.85,
310 images: ImageRedaction::Drop,
311 return_mapping: false,
312 }
313 }
314}
315
316impl RedactionOptions {
317 pub fn wants(&self, kind: PiiKind) -> bool {
319 self.kinds.is_empty() || self.kinds.contains(&kind)
320 }
321
322 pub fn wants_ner(&self) -> bool {
324 PiiKind::NER.iter().any(|&k| self.wants(k))
325 }
326}
327
328#[derive(Debug, Clone, PartialEq)]
332pub struct Span {
333 pub start: usize,
334 pub end: usize,
335 pub kind: PiiKind,
336 pub score: f32,
337 pub name: Option<String>,
338}
339
340impl Span {
341 pub fn new(start: usize, end: usize, kind: PiiKind) -> Self {
342 Span {
343 start,
344 end,
345 kind,
346 score: 1.0,
347 name: None,
348 }
349 }
350}
351
352pub trait PiiDetector {
356 fn detect(&self, text: &str) -> Vec<Span>;
357}
358
359pub struct CompositeDetector(pub Vec<Box<dyn PiiDetector + Send + Sync>>);
362
363impl PiiDetector for CompositeDetector {
364 fn detect(&self, text: &str) -> Vec<Span> {
365 self.0.iter().flat_map(|d| d.detect(text)).collect()
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Default)]
375pub struct RedactionReport {
376 pub counts: BTreeMap<String, usize>,
377 pub total: usize,
378 pub mapping: Option<Vec<(String, String)>>,
379}
380
381impl RedactionReport {
382 pub fn counts_json(&self) -> serde_json::Value {
385 serde_json::Value::Object(
386 self.counts
387 .iter()
388 .map(|(k, v)| (k.clone(), serde_json::json!(v)))
389 .collect(),
390 )
391 }
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct RedactionError(pub String);
397
398impl fmt::Display for RedactionError {
399 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400 f.write_str(&self.0)
401 }
402}
403
404impl std::error::Error for RedactionError {}
405
406macro_rules! re {
411 ($pat:expr) => {{
412 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
413 RE.get_or_init(|| Regex::new($pat).expect("static regex"))
414 }};
415}
416
417#[derive(Debug, Default)]
422pub struct AllowList {
423 terms: Vec<String>,
424 patterns: Vec<Regex>,
425}
426
427impl AllowList {
428 pub fn new(opts: &RedactionOptions) -> Result<Self, RedactionError> {
431 let terms = opts
432 .allow_terms
433 .iter()
434 .map(|t| t.trim().to_lowercase())
435 .filter(|t| !t.is_empty())
436 .collect();
437 let mut patterns = Vec::new();
438 for p in opts
439 .allow_patterns
440 .iter()
441 .map(|p| p.trim())
442 .filter(|p| !p.is_empty())
443 {
444 let re = Regex::new(&format!("^(?:{p})$"))
445 .map_err(|e| RedactionError(format!("redact allow pattern {p:?}: {e}")))?;
446 patterns.push(re);
447 }
448 Ok(Self { terms, patterns })
449 }
450
451 pub fn allows(&self, text: &str) -> bool {
453 (!self.terms.is_empty() && self.terms.iter().any(|a| a == &text.to_lowercase()))
454 || self.patterns.iter().any(|re| re.is_match(text))
455 }
456}
457
458pub struct PatternDetector {
462 kinds: Vec<PiiKind>,
463 custom: Vec<(String, Regex)>,
464 deny: Vec<Regex>,
465 allow: AllowList,
466}
467
468impl PatternDetector {
469 pub fn new(opts: &RedactionOptions) -> Result<Self, RedactionError> {
473 let kinds = PiiKind::PATTERN
474 .into_iter()
475 .filter(|&k| opts.wants(k))
476 .collect();
477 let mut custom = Vec::new();
478 for p in &opts.custom_patterns {
479 let re = Regex::new(&p.regex)
480 .map_err(|e| RedactionError(format!("redact pattern {:?}: {e}", p.name)))?;
481 custom.push((p.name.trim().to_ascii_uppercase().replace('-', "_"), re));
482 }
483 let deny = opts
484 .deny_terms
485 .iter()
486 .filter(|t| !t.trim().is_empty())
487 .map(|t| {
488 Regex::new(&format!(r"(?i)\b{}\b", regex::escape(t.trim()))).expect("escaped term")
489 })
490 .collect();
491 let allow = AllowList::new(opts)?;
492 Ok(Self {
493 kinds,
494 custom,
495 deny,
496 allow,
497 })
498 }
499
500 fn wants(&self, kind: PiiKind) -> bool {
501 self.kinds.contains(&kind)
502 }
503
504 pub fn allowed(&self, text: &str) -> bool {
507 self.allow.allows(text)
508 }
509}
510
511impl PiiDetector for PatternDetector {
512 fn detect(&self, text: &str) -> Vec<Span> {
513 let mut out = Vec::new();
514 if text.is_empty() {
515 return out;
516 }
517 let mut credentials: Vec<(usize, usize)> = Vec::new();
521 if self.wants(PiiKind::UrlCredentials) {
522 for c in re!(r"(?i)[a-z][a-z0-9+.\-]*://([^\s/@:]+:[^\s/@]+)@").captures_iter(text) {
523 let m = c.get(1).expect("group 1");
524 credentials.push((m.start(), m.end()));
525 out.push(Span::new(m.start(), m.end(), PiiKind::UrlCredentials));
526 }
527 }
528 if self.wants(PiiKind::Email) {
529 for m in re!(r"(?i)[a-z0-9._%+\-\\]+@[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)*\.[a-z]{2,}").find_iter(text) {
530 let (mut s, mut e) = (m.start(), m.end());
533 while e > s && text.as_bytes()[e - 1] == b'.' {
534 e -= 1;
535 }
536 while s < e && text.as_bytes()[s] == b'\\' {
537 s += 1;
538 }
539 if s < e && !credentials.iter().any(|&(cs, ce)| s < ce && cs < e) {
540 out.push(Span::new(s, e, PiiKind::Email));
541 }
542 }
543 }
544 if self.wants(PiiKind::CreditCard) {
545 for m in re!(r"\b\d(?:[ \-]?\d){12,18}\b").find_iter(text) {
546 let digits: String = m.as_str().chars().filter(|c| c.is_ascii_digit()).collect();
547 if !maximal_digit_run(text, m.start(), m.end())
550 || !token_bounded(text, m.start(), m.end())
551 {
552 continue;
553 }
554 if card_brand(&digits).is_some()
558 && card_grouping_ok(m.as_str())
559 && luhn_valid(&digits)
560 {
561 out.push(Span::new(m.start(), m.end(), PiiKind::CreditCard));
562 }
563 }
564 }
565 if self.wants(PiiKind::Iban) {
566 for m in
567 re!(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b").find_iter(text)
568 {
569 let compact: String = m.as_str().chars().filter(|c| *c != ' ').collect();
570 if iban_valid(&compact) {
571 out.push(Span::new(m.start(), m.end(), PiiKind::Iban));
572 }
573 }
574 }
575 if self.wants(PiiKind::IpAddress) {
576 for m in re!(r"\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\b").find_iter(text) {
577 let before = text[..m.start()].chars().next_back();
579 let after = text[m.end()..].chars().next();
580 if before == Some('.') || after == Some('.') {
581 continue;
582 }
583 if matches!(before, Some('v') | Some('V')) {
585 continue;
586 }
587 out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
588 }
589 for m in re!(r"[0-9A-Fa-f:]{3,45}").find_iter(text) {
590 let s = m.as_str();
591 if s.matches(':').count() < 2 || !s.chars().any(|c| c.is_ascii_hexdigit()) {
592 continue;
593 }
594 if s.parse::<std::net::Ipv6Addr>().is_ok() {
595 out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
596 }
597 }
598 }
599 if self.wants(PiiKind::NationalId) {
600 for m in re!(r"\b\d{3}-\d{2}-\d{4}\b").find_iter(text) {
604 let s = m.as_str();
605 let area = &s[0..3];
606 if area == "000" || area == "666" || area.starts_with('9') {
607 continue;
608 }
609 if &s[4..6] == "00" || &s[7..11] == "0000" {
610 continue;
611 }
612 if !token_bounded(text, m.start(), m.end()) || citation_before(text, m.start()) {
613 continue;
614 }
615 out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
616 }
617 for m in re!(
621 r"\b[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z](?:\d{6}[A-D]| \d{2} \d{2} \d{2} [A-D])\b"
622 )
623 .find_iter(text)
624 {
625 let prefix = &m.as_str()[0..2];
626 if ["BG", "GB", "NK", "KN", "TN", "NT", "ZZ"].contains(&prefix) {
627 continue;
628 }
629 if !token_bounded(text, m.start(), m.end()) {
630 continue;
631 }
632 out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
633 }
634 for m in re!(r"\b[2-9]\d{3}([ \-]?)\d{4}([ \-]?)\d{4}\b").captures_iter(text) {
639 let whole = m.get(0).expect("match");
640 let (sep1, sep2) = (
641 m.get(1).map_or("", |g| g.as_str()),
642 m.get(2).map_or("", |g| g.as_str()),
643 );
644 if sep1 != sep2 || !token_bounded(text, whole.start(), whole.end()) {
645 continue;
646 }
647 let s = whole.as_str();
648 if !sep1.is_empty() && (s.starts_with("19") || s.starts_with("20")) {
649 continue;
650 }
651 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
652 if verhoeff_valid(&digits) {
653 out.push(Span::new(whole.start(), whole.end(), PiiKind::NationalId));
654 }
655 }
656 }
657 if self.wants(PiiKind::Phone) {
658 let ssn_shape = re!(r"^\d{3}-\d{2}-\d{4}$");
659 for m in re!(r"(?:\+\d{1,3}[ .\-]?)?(?:\(\d{1,4}\)[ .\-]?)?\d{2,4}(?:[ .\-]\d{2,4}){1,4}\b|\+\d{7,15}\b").find_iter(text) {
660 let s = m.as_str();
661 let digits = s.chars().filter(|c| c.is_ascii_digit()).count();
662 if !(7..=15).contains(&digits) || looks_like_date_or_version(s) {
663 continue;
664 }
665 if ssn_shape.is_match(s) {
667 continue;
668 }
669 if !token_bounded(text, m.start(), m.end()) {
673 continue;
674 }
675 if !phone_shape(s, digits) && !phone_keyword_before(text, m.start()) {
682 continue;
683 }
684 out.push(Span::new(m.start(), m.end(), PiiKind::Phone));
685 }
686 }
687 for (name, re) in &self.custom {
688 for c in re.captures_iter(text) {
689 let m = c.get(1).or_else(|| c.get(0)).expect("match");
690 if m.start() == m.end() {
691 continue;
692 }
693 out.push(Span {
694 start: m.start(),
695 end: m.end(),
696 kind: PiiKind::Custom,
697 score: 1.0,
698 name: Some(name.clone()),
699 });
700 }
701 }
702 for re in &self.deny {
703 for m in re.find_iter(text) {
704 out.push(Span::new(m.start(), m.end(), PiiKind::Custom));
705 }
706 }
707 out.retain(|s| !self.allowed(&text[s.start..s.end]));
708 out
709 }
710}
711
712fn maximal_digit_run(text: &str, start: usize, end: usize) -> bool {
715 let bytes = text.as_bytes();
716 let before =
717 start >= 2 && matches!(bytes[start - 1], b' ' | b'-') && bytes[start - 2].is_ascii_digit();
718 let after = end + 1 < bytes.len()
719 && matches!(bytes[end], b' ' | b'-')
720 && bytes[end + 1].is_ascii_digit();
721 !before && !after
722}
723
724fn token_bounded(text: &str, start: usize, end: usize) -> bool {
728 let before = text[..start].chars().next_back();
729 let after = text[end..].chars().next();
730 let opens_itself = text[start..].starts_with(['+', '(']);
734 let left_ok = match before {
735 Some(c) if opens_itself => !c.is_ascii_digit(),
736 Some(c) => !c.is_alphanumeric(),
737 None => true,
738 };
739 left_ok && !after.is_some_and(|c| c.is_alphanumeric())
740}
741
742fn phone_shape(s: &str, digits: usize) -> bool {
748 if s.starts_with('+') || s.contains('(') {
749 return true;
750 }
751 let groups: Vec<usize> = s
752 .split(|c: char| !c.is_ascii_digit())
753 .filter(|g| !g.is_empty())
754 .map(str::len)
755 .collect();
756 groups == [3, 3, 4]
757 || groups == [1, 3, 3, 4]
758 || (groups.len() >= 3
759 && ((10..=11).contains(&digits) || (s.starts_with('0') && digits <= 13)))
760}
761
762fn phone_keyword_before(text: &str, pos: usize) -> bool {
765 let head = &text[..pos];
766 let from = head.char_indices().rev().nth(30).map_or(0, |(i, _)| i);
767 re!(r"(?i)\b(?:tel|tel\.|telephone|phone|fax|mobile|cell|call|contact|whatsapp|hotline|dial|sms|text)\b[^\d]*$")
768 .is_match(&head[from..])
769}
770
771fn citation_before(text: &str, pos: usize) -> bool {
775 let head = text[..pos].trim_end();
776 let from = head.char_indices().rev().nth(16).map_or(0, |(i, _)| i);
777 re!(r"(?i)(?:\b(?:rule|rules|section|sections|sec|code|article|art|para|paragraph|clause|chapter|title|regulation|reg|order|statute|part|no|number)\.?|§+)\s*(?:no\.?\s*)?$")
778 .is_match(&head[from..])
779}
780
781fn card_brand(digits: &str) -> Option<&'static str> {
784 let n = digits.len();
785 let p = |k: usize| digits[..k.min(n)].parse::<u32>().unwrap_or(0);
786 let (two, three, four) = (p(2), p(3), p(4));
787 if digits.starts_with('4') && matches!(n, 13 | 16 | 19) {
788 return Some("visa");
789 }
790 if ((51..=55).contains(&two) || (2221..=2720).contains(&four)) && n == 16 {
791 return Some("mastercard");
792 }
793 if (two == 34 || two == 37) && n == 15 {
794 return Some("amex");
795 }
796 if (four == 6011 || two == 65 || (644..=649).contains(&three)) && (16..=19).contains(&n) {
797 return Some("discover");
798 }
799 if ((300..=305).contains(&three) || two == 36 || two == 38 || two == 39)
800 && (14..=19).contains(&n)
801 {
802 return Some("diners");
803 }
804 if (3528..=3589).contains(&four) && (16..=19).contains(&n) {
805 return Some("jcb");
806 }
807 if two == 62 && (16..=19).contains(&n) {
808 return Some("unionpay");
809 }
810 if matches!(
811 four,
812 5018 | 5020 | 5038 | 5893 | 6304 | 6759 | 6761 | 6762 | 6763
813 ) && (12..=19).contains(&n)
814 {
815 return Some("maestro");
816 }
817 None
818}
819
820fn card_grouping_ok(s: &str) -> bool {
824 let groups: Vec<usize> = s
825 .split(|c: char| !c.is_ascii_digit())
826 .filter(|g| !g.is_empty())
827 .map(str::len)
828 .collect();
829 if groups.len() == 1 {
830 return true;
831 }
832 let (last, head) = groups.split_last().expect("non-empty");
833 (head.iter().all(|&g| g == 4) && (1..=4).contains(last))
834 || groups == [4, 6, 5]
835 || groups == [4, 6, 4]
836}
837
838pub fn luhn_valid(digits: &str) -> bool {
840 let mut sum = 0u32;
841 let mut double = false;
842 for c in digits.chars().rev() {
843 let Some(d) = c.to_digit(10) else {
844 return false;
845 };
846 let d = if double {
847 let x = d * 2;
848 if x > 9 {
849 x - 9
850 } else {
851 x
852 }
853 } else {
854 d
855 };
856 sum += d;
857 double = !double;
858 }
859 digits.len() >= 2 && sum % 10 == 0
860}
861
862fn iban_length(country: &str) -> Option<usize> {
866 Some(match country {
867 "AL" | "AZ" | "CY" | "DO" | "GT" | "HU" | "LB" | "PL" | "BY" | "SV" | "NI" => 28,
868 "AD" | "CZ" | "MD" | "PK" | "RO" | "SA" | "SK" | "ES" | "SE" | "TN" | "VG" => 24,
869 "AT" | "BA" | "EE" | "KZ" | "LT" | "LU" | "XK" | "MN" => 20,
870 "BH" | "BG" | "CR" | "GE" | "DE" | "IE" | "ME" | "RS" | "GB" | "VA" => 22,
871 "BE" | "BI" => 16,
872 "BR" | "PS" | "QA" | "UA" | "EG" => 29,
873 "HR" | "LI" | "CH" => 21,
874 "DK" | "FO" | "FI" | "GL" | "NL" | "SD" | "FK" => 18,
875 "FR" | "GR" | "IT" | "MR" | "MC" | "SM" | "DJ" => 27,
876 "GI" | "IL" | "AE" | "IQ" | "TL" | "SO" | "OM" => 23,
877 "IS" => 26,
878 "JO" | "KW" | "MU" | "YE" => 30,
879 "LV" => 21,
880 "MK" | "SI" => 19,
881 "MT" => 31,
882 "NO" => 15,
883 "PT" | "ST" | "LY" => 25,
884 "TR" => 26,
885 "LC" => 32,
886 "RU" => 33,
887 _ => return None,
888 })
889}
890
891pub fn iban_valid(iban: &str) -> bool {
893 if iban.len() < 15 || iban.len() > 34 || !iban.is_ascii() {
894 return false;
895 }
896 let country = &iban[0..2];
897 if iban_length(country) != Some(iban.len()) {
898 return false;
899 }
900 let rearranged = format!("{}{}", &iban[4..], &iban[..4]);
901 let mut rem = 0u32;
902 for c in rearranged.chars() {
903 let v = match c {
904 '0'..='9' => c as u32 - '0' as u32,
905 'A'..='Z' => c as u32 - 'A' as u32 + 10,
906 _ => return false,
907 };
908 rem = if v >= 10 {
909 (rem * 100 + v) % 97
910 } else {
911 (rem * 10 + v) % 97
912 };
913 }
914 rem == 1
915}
916
917pub fn verhoeff_valid(digits: &str) -> bool {
919 const D: [[u8; 10]; 10] = [
920 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
921 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
922 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
923 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
924 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
925 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
926 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
927 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
928 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
929 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
930 ];
931 const P: [[u8; 10]; 8] = [
932 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
933 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
934 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
935 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
936 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
937 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
938 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
939 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
940 ];
941 let mut c = 0u8;
942 for (i, ch) in digits.chars().rev().enumerate() {
943 let Some(d) = ch.to_digit(10) else {
944 return false;
945 };
946 c = D[c as usize][P[i % 8][d as usize] as usize];
947 }
948 !digits.is_empty() && c == 0
949}
950
951fn looks_like_date_or_version(s: &str) -> bool {
954 let s = s.trim();
955 re!(r"^\d{4}[\-./]\d{1,2}[\-./]\d{1,2}$").is_match(s)
956 || re!(r"^\d{1,2}[\-./]\d{1,2}[\-./]\d{2,4}$").is_match(s)
957 || (s.contains('.') && !s.contains(' ') && !s.contains('-') && !s.starts_with('+'))
959}
960
961pub struct Redactor<'a> {
970 opts: &'a RedactionOptions,
971 detector: &'a dyn PiiDetector,
972 allow: AllowList,
976 numbers: HashMap<(String, String), usize>,
978 next: HashMap<String, usize>,
979 report: RedactionReport,
980 counting: bool,
984}
985
986impl<'a> Redactor<'a> {
987 pub fn new(opts: &'a RedactionOptions, detector: &'a dyn PiiDetector) -> Self {
988 Redactor {
989 opts,
990 detector,
991 allow: AllowList::new(opts).unwrap_or_default(),
992 numbers: HashMap::new(),
993 next: HashMap::new(),
994 report: RedactionReport {
995 mapping: opts.return_mapping.then(Vec::new),
996 ..Default::default()
997 },
998 counting: true,
999 }
1000 }
1001
1002 pub fn report(&self) -> &RedactionReport {
1004 &self.report
1005 }
1006
1007 pub fn finish(self) -> RedactionReport {
1008 self.report
1009 }
1010
1011 fn spans(&self, text: &str) -> Vec<Span> {
1016 let mut spans: Vec<Span> = self
1017 .detector
1018 .detect(text)
1019 .into_iter()
1020 .filter(|s| s.start < s.end && s.end <= text.len())
1021 .filter(|s| text.is_char_boundary(s.start) && text.is_char_boundary(s.end))
1022 .filter(|s| self.opts.wants(s.kind))
1023 .filter(|s| !PiiKind::NER.contains(&s.kind) || s.score >= self.opts.ner_min_score)
1024 .filter(|s| !self.allow.allows(&text[s.start..s.end]))
1025 .collect();
1026 spans.sort_by(|a, b| {
1027 a.start
1028 .cmp(&b.start)
1029 .then((b.end - b.start).cmp(&(a.end - a.start)))
1030 .then(a.kind.cmp(&b.kind))
1031 });
1032 let mut kept: Vec<Span> = Vec::with_capacity(spans.len());
1033 for s in spans {
1034 match kept.last() {
1035 Some(last) if s.start < last.end => {
1036 if s.end - s.start > last.end - last.start {
1038 kept.pop();
1039 kept.push(s);
1040 }
1041 }
1042 _ => kept.push(s),
1043 }
1044 }
1045 kept
1046 }
1047
1048 pub fn has_pii(&self, text: &str) -> bool {
1051 !self.spans(text).is_empty()
1052 }
1053
1054 fn label_of(span: &Span) -> String {
1055 span.name
1056 .clone()
1057 .unwrap_or_else(|| span.kind.label().to_string())
1058 }
1059
1060 fn placeholder(&mut self, span: &Span, original: &str) -> String {
1062 let label = Self::label_of(span);
1063 if self.counting {
1064 *self.report.counts.entry(label.clone()).or_insert(0) += 1;
1065 self.report.total += 1;
1066 }
1067 let out = match &self.opts.replacement {
1068 Replacement::Label => format!("[{label}]"),
1069 Replacement::Fixed(s) => s.clone(),
1070 Replacement::Pseudonym => {
1071 let key = normalize(original);
1074 let n = match self.numbers.get(&(label.clone(), key.clone())) {
1075 Some(n) => *n,
1076 None => {
1077 let next = self.next.entry(label.clone()).or_insert(0);
1078 *next += 1;
1079 self.numbers.insert((label.clone(), key), *next);
1080 *next
1081 }
1082 };
1083 format!("[{label}_{n}]")
1084 }
1085 };
1086 if let Some(map) = self.report.mapping.as_mut() {
1087 if !map.iter().any(|(o, _)| o == original) {
1088 map.push((original.to_string(), out.clone()));
1089 }
1090 }
1091 out
1092 }
1093
1094 pub fn redact_text(&mut self, text: &mut String) {
1096 let spans = self.spans(text);
1097 if spans.is_empty() {
1098 return;
1099 }
1100 let mut out = String::with_capacity(text.len());
1101 let mut pos = 0;
1102 for span in &spans {
1103 out.push_str(&text[pos..span.start]);
1104 let original = text[span.start..span.end].to_string();
1105 out.push_str(&self.placeholder(span, &original));
1106 pos = span.end;
1107 }
1108 out.push_str(&text[pos..]);
1109 *text = out;
1110 }
1111
1112 fn redact_opt(&mut self, text: &mut Option<String>) {
1113 if let Some(t) = text.as_mut() {
1114 self.redact_text(t);
1115 }
1116 }
1117
1118 pub fn redact_runs(&mut self, runs: &mut [InlineRun]) {
1122 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
1123 let spans = self.spans(&joined);
1124 if spans.is_empty() {
1125 return;
1126 }
1127 let mut starts = Vec::with_capacity(runs.len());
1129 let mut acc = 0;
1130 for r in runs.iter() {
1131 starts.push(acc);
1132 acc += r.text.len();
1133 }
1134 let mut placeholders: Vec<String> = Vec::with_capacity(spans.len());
1138 for span in &spans {
1139 let original = joined[span.start..span.end].to_string();
1140 placeholders.push(self.placeholder(span, &original));
1141 }
1142 for (i, run) in runs.iter_mut().enumerate() {
1143 let (rs, re) = (starts[i], starts[i] + run.text.len());
1144 let mut out = String::new();
1145 let mut pos = rs;
1146 for (span, ph) in spans.iter().zip(&placeholders) {
1147 if span.end <= rs || span.start >= re {
1148 continue;
1149 }
1150 let s = span.start.max(rs);
1151 out.push_str(&joined[pos..s]);
1152 if span.start >= rs {
1153 out.push_str(ph);
1154 }
1155 pos = span.end.min(re);
1156 }
1157 out.push_str(&joined[pos..re]);
1158 run.text = out;
1159 }
1160 }
1161
1162 fn redact_table(&mut self, table: &mut Table) {
1163 for row in table.rows.iter_mut() {
1164 for cell in row.iter_mut() {
1165 self.redact_text(cell);
1166 }
1167 }
1168 if let Some(cells) = table.cells.as_mut() {
1169 for c in cells.iter_mut() {
1170 self.redact_text(&mut c.text);
1171 }
1172 }
1173 if let Some(blocks) = table.cell_blocks.as_mut() {
1174 for row in blocks.iter_mut() {
1175 for cell in row.iter_mut() {
1176 self.redact_nodes(cell);
1177 }
1178 }
1179 }
1180 self.redact_opt(&mut table.caption);
1181 }
1182
1183 fn redact_fields(&mut self, items: &mut [crate::FieldItem]) {
1184 for f in items.iter_mut() {
1185 self.redact_opt(&mut f.marker);
1186 self.redact_opt(&mut f.key);
1187 self.redact_opt(&mut f.value);
1188 }
1189 }
1190
1191 fn redact_graph(&mut self, cells: &mut [crate::GraphCell]) {
1192 for c in cells.iter_mut() {
1193 self.redact_text(&mut c.text);
1194 self.redact_text(&mut c.orig);
1195 }
1196 }
1197
1198 fn redact_image(&mut self, image: &mut Option<crate::PictureImage>) {
1199 if self.opts.images == ImageRedaction::Drop {
1200 *image = None;
1201 }
1202 }
1203
1204 pub fn redact_nodes(&mut self, nodes: &mut [Node]) {
1206 for node in nodes.iter_mut() {
1207 self.redact_node(node);
1208 }
1209 }
1210
1211 fn redact_node(&mut self, node: &mut Node) {
1212 match node {
1213 Node::Heading { text, .. }
1214 | Node::Paragraph { text }
1215 | Node::CheckboxItem { text, .. }
1216 | Node::PageFurniture { text, .. }
1217 | Node::FurnitureText { text, .. }
1218 | Node::TextDump(text) => self.redact_text(text),
1219 Node::ListItem {
1220 text,
1221 marker,
1222 dclx,
1223 href,
1224 ..
1225 } => {
1226 self.redact_text(text);
1227 self.redact_opt(marker);
1228 self.redact_opt(href);
1229 if let Some(d) = dclx.as_mut() {
1230 self.redact_text(&mut d.text);
1231 self.redact_opt(&mut d.marker);
1232 self.redact_runs(&mut d.runs);
1233 }
1234 }
1235 Node::Code {
1236 text, orig, pretty, ..
1237 } => {
1238 self.redact_text(text);
1239 self.redact_opt(orig);
1240 self.redact_opt(pretty);
1241 }
1242 Node::Table(t) => self.redact_table(t),
1243 Node::Picture {
1244 caption,
1245 caption_href,
1246 image,
1247 description,
1248 ..
1249 } => {
1250 self.redact_opt(caption);
1251 self.redact_opt(caption_href);
1252 if let Some(d) = description.as_mut() {
1255 self.redact_text(&mut d.text);
1256 }
1257 self.redact_image(image);
1258 }
1259 Node::Formula { orig, .. } => self.redact_text(orig),
1262 Node::Caption { text, href } | Node::LabeledText { text, href, .. } => {
1263 self.redact_text(text);
1264 self.redact_opt(href);
1265 }
1266 Node::Chart { table, caption, .. } => {
1267 self.redact_table(table);
1268 self.redact_opt(caption);
1269 }
1270 Node::Group { name, children, .. } => {
1271 self.redact_opt(name);
1272 self.redact_nodes(children);
1273 }
1274 Node::FieldRegion { items } => self.redact_fields(items),
1275 Node::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1276 Node::InlineGroup { runs, md_text, .. } => {
1277 self.redact_runs(runs);
1278 self.redact_text(md_text);
1279 }
1280 Node::Furniture { inner, .. }
1281 | Node::Commented { inner, .. }
1282 | Node::Located { inner, .. }
1283 | Node::Prov { inner, .. }
1284 | Node::DoclangOnly(inner) => self.redact_node(inner),
1285 Node::Track { track, cue, inner } => {
1290 self.redact_opt(&mut track.voice);
1291 let original = cue.clone();
1292 self.redact_text(cue);
1293 match inner.as_mut() {
1294 Node::Paragraph { text, .. } if text.ends_with(&original) => {
1295 let prefix_len = text.len() - original.len();
1296 text.truncate(prefix_len);
1297 text.push_str(cue);
1298 }
1299 other => {
1300 let counting = std::mem::replace(&mut self.counting, false);
1301 self.redact_node(other);
1302 self.counting = counting;
1303 }
1304 }
1305 }
1306 Node::CommentSection { name, text, .. } => {
1307 self.redact_text(name);
1308 self.redact_text(text);
1309 }
1310 Node::PictureChildren(children) => self.redact_nodes(children),
1311 Node::PageBreak | Node::PageInfo { .. } => {}
1312 }
1313 }
1314
1315 pub fn redact_tree(&mut self, tree: &mut ItemTree) {
1320 let counting = std::mem::replace(&mut self.counting, false);
1321 self.redact_tree_items(tree);
1322 self.counting = counting;
1323 }
1324
1325 fn redact_tree_items(&mut self, tree: &mut ItemTree) {
1326 for item in tree.items.iter_mut() {
1327 match &mut item.kind {
1328 TreeKind::Text {
1329 text,
1330 orig,
1331 hyperlink,
1332 list,
1333 ..
1334 } => {
1335 self.redact_text(text);
1336 self.redact_opt(orig);
1337 self.redact_opt(hyperlink);
1338 if let Some(l) = list.as_mut() {
1339 self.redact_text(&mut l.marker);
1340 }
1341 }
1342 TreeKind::Code {
1343 text,
1344 orig,
1345 hyperlink,
1346 ..
1347 } => {
1348 self.redact_text(text);
1349 self.redact_opt(orig);
1350 self.redact_opt(hyperlink);
1351 }
1352 TreeKind::Group { name, .. } => self.redact_text(name),
1353 TreeKind::Table { table, .. } => self.redact_table(table),
1354 TreeKind::Picture {
1355 image,
1356 chart,
1357 description,
1358 ..
1359 } => {
1360 self.redact_image(image);
1361 if let Some(d) = description.as_mut() {
1362 self.redact_text(&mut d.text);
1363 }
1364 if let Some(t) = chart.as_mut() {
1365 self.redact_table(t);
1366 }
1367 }
1368 TreeKind::FieldRegion { items } => self.redact_fields(items),
1369 TreeKind::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1370 }
1371 if let Some(track) = item.source.as_mut() {
1372 self.redact_opt(&mut track.voice);
1373 }
1374 for note in item.notes.iter_mut() {
1375 self.redact_text(&mut note.text);
1376 }
1377 }
1378 }
1379
1380 pub fn redact_links(&mut self, links: &mut [(String, String)]) {
1382 for (text, href) in links.iter_mut() {
1383 self.redact_text(text);
1384 self.redact_text(href);
1385 }
1386 }
1387
1388 pub fn redact_document(&mut self, doc: &mut DoclingDocument) {
1390 self.redact_nodes(&mut doc.nodes);
1391 if let Some(tree) = doc.tree.as_mut() {
1392 self.redact_tree(tree);
1393 }
1394 self.redact_links(&mut doc.links);
1395 self.redact_metadata(&mut doc.metadata);
1396 if self.opts.images == ImageRedaction::Drop {
1397 doc.page_images.clear();
1398 }
1399 }
1400
1401 pub fn redact_metadata(&mut self, m: &mut crate::metadata::DocumentMetadata) {
1411 if self.opts.wants(PiiKind::Person) {
1412 for a in m.authors.iter_mut() {
1413 self.redact_whole(a, PiiKind::Person);
1414 }
1415 if let Some(by) = m.last_modified_by.as_mut() {
1416 self.redact_whole(by, PiiKind::Person);
1417 }
1418 for key in ["Artist", "XPAuthor"] {
1419 if let Some(v) = m.exif.get_mut(key) {
1420 self.redact_whole(v, PiiKind::Person);
1421 }
1422 }
1423 for key in ["artist", "album_artist", "composer", "performer"] {
1424 if let Some(v) = m.audio.get_mut(key) {
1425 self.redact_whole(v, PiiKind::Person);
1426 }
1427 }
1428 }
1429 if self.opts.wants(PiiKind::Location) {
1430 let had_gps = m.gps.take().is_some();
1431 let before = m.exif.len();
1432 m.exif.retain(|k, _| !k.starts_with("GPS"));
1433 if had_gps || m.exif.len() != before {
1434 self.count(PiiKind::Location.label());
1435 }
1436 }
1437 self.redact_opt(&mut m.title);
1438 self.redact_opt(&mut m.subject);
1439 self.redact_opt(&mut m.description);
1440 for k in m.keywords.iter_mut() {
1441 self.redact_text(k);
1442 }
1443 self.redact_opt(&mut m.publisher);
1444 self.redact_opt(&mut m.rights);
1445 self.redact_opt(&mut m.identifier);
1446 self.redact_opt(&mut m.creator_tool);
1447 for v in m.properties.values_mut() {
1448 self.redact_text(v);
1449 }
1450 for (k, v) in m.exif.iter_mut() {
1451 if k != "Artist" && k != "XPAuthor" {
1452 self.redact_text(v);
1453 }
1454 }
1455 for (k, v) in m.audio.iter_mut() {
1456 if !matches!(
1457 k.as_str(),
1458 "artist" | "album_artist" | "composer" | "performer"
1459 ) {
1460 self.redact_text(v);
1461 }
1462 }
1463 }
1464
1465 fn redact_whole(&mut self, text: &mut String, kind: PiiKind) {
1468 if text.trim().is_empty() || self.allow.allows(text) {
1469 return;
1470 }
1471 let span = Span::new(0, text.len(), kind);
1472 let original = text.clone();
1473 *text = self.placeholder(&span, &original);
1474 }
1475
1476 fn count(&mut self, label: &str) {
1478 if self.counting {
1479 *self.report.counts.entry(label.to_string()).or_insert(0) += 1;
1480 self.report.total += 1;
1481 }
1482 }
1483}
1484
1485fn normalize(s: &str) -> String {
1489 s.to_lowercase()
1490 .chars()
1491 .filter(|c| !c.is_whitespace() && *c != '-' && *c != '.' && *c != '(' && *c != ')')
1492 .collect()
1493}
1494
1495impl DoclingDocument {
1496 pub fn redact(
1500 &mut self,
1501 opts: &RedactionOptions,
1502 detector: &dyn PiiDetector,
1503 ) -> RedactionReport {
1504 let mut r = Redactor::new(opts, detector);
1505 r.redact_document(self);
1506 r.finish()
1507 }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512 use super::*;
1513
1514 fn detector(opts: &RedactionOptions) -> PatternDetector {
1515 PatternDetector::new(opts).unwrap()
1516 }
1517
1518 fn redact(text: &str) -> String {
1519 let opts = RedactionOptions::default();
1520 let d = detector(&opts);
1521 let mut s = text.to_string();
1522 Redactor::new(&opts, &d).redact_text(&mut s);
1523 s
1524 }
1525
1526 fn kinds(text: &str) -> Vec<PiiKind> {
1527 let opts = RedactionOptions::default();
1528 let d = detector(&opts);
1529 Redactor::new(&opts, &d)
1530 .spans(text)
1531 .into_iter()
1532 .map(|s| s.kind)
1533 .collect()
1534 }
1535
1536 #[test]
1537 fn emails_including_markdown_links_and_escapes() {
1538 assert_eq!(
1539 redact("Write to [john.doe@example.com](mailto:john.doe@example.com)."),
1540 "Write to [[EMAIL]](mailto:[EMAIL])."
1541 );
1542 assert_eq!(
1544 redact(r"jane\_doe@example.org, please."),
1545 "[EMAIL], please."
1546 );
1547 assert_eq!(redact("a@b"), "a@b");
1548 }
1549
1550 #[test]
1551 fn cards_need_luhn_and_ibans_need_mod97() {
1552 assert_eq!(kinds("card 4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1553 assert_eq!(kinds("card 4111-1111-1111-1111"), vec![PiiKind::CreditCard]);
1554 assert_eq!(kinds("order 4111 1111 1111 1112"), vec![]);
1555 assert_eq!(
1556 kinds("IBAN DE89 3704 0044 0532 0130 00"),
1557 vec![PiiKind::Iban]
1558 );
1559 assert_eq!(kinds("IBAN GB82WEST12345698765432"), vec![PiiKind::Iban]);
1560 assert_eq!(kinds("IBAN DE89 3704 0044 0532 0130 01"), vec![]);
1561 assert_eq!(kinds("code XY12 ABCD 1234 EFGH 5678"), vec![]);
1562 }
1563
1564 #[test]
1565 fn phones_but_not_dates_or_versions() {
1566 assert_eq!(kinds("call +1 (555) 123-4567"), vec![PiiKind::Phone]);
1567 assert_eq!(kinds("call 555-123-4567 today"), vec![PiiKind::Phone]);
1568 assert_eq!(kinds("tel +44 20 7946 0958"), vec![PiiKind::Phone]);
1569 assert_eq!(kinds("on 2024-01-15 and 15.01.2024 and 01/15/24"), vec![]);
1570 assert_eq!(kinds("version 1.2.3 and v10.0.1 and v2.0.0.1"), vec![]);
1571 assert_eq!(kinds("ISBN 978-3-16-148410-0"), vec![]);
1572 }
1573
1574 #[test]
1575 fn ip_addresses_and_url_credentials() {
1576 assert_eq!(kinds("host 192.168.1.10 up"), vec![PiiKind::IpAddress]);
1577 assert_eq!(kinds("v 1.2.3.4.5"), vec![]);
1578 assert_eq!(
1579 kinds("addr 2001:db8::1 and ::1"),
1580 vec![PiiKind::IpAddress, PiiKind::IpAddress]
1581 );
1582 assert_eq!(kinds("at 12:30:45 today"), vec![]);
1583 assert_eq!(kinds("mac aa:bb:cc:dd:ee:ff"), vec![]);
1584 assert_eq!(
1585 redact("see https://alice:s3cret@db.example.com/x"),
1586 "see https://[CREDENTIALS]@db.example.com/x"
1587 );
1588 }
1589
1590 #[test]
1591 fn national_ids_with_their_rules() {
1592 assert_eq!(kinds("SSN 123-45-6789"), vec![PiiKind::NationalId]);
1593 assert_eq!(
1594 kinds("not 000-45-6789 nor 123-00-6789 nor 900-45-6789"),
1595 vec![]
1596 );
1597 assert_eq!(kinds("NINO AB 12 34 56 C"), vec![PiiKind::NationalId]);
1598 assert_eq!(kinds("NINO QQ 12 34 56 C"), vec![]);
1599 assert_eq!(kinds("NINO BG 12 34 56 C"), vec![]);
1600 let base = "22345678901";
1603 let ok = (0..10)
1604 .map(|d| format!("{base}{d}"))
1605 .find(|s| verhoeff_valid(s))
1606 .unwrap();
1607 assert_eq!(kinds(&format!("Aadhaar {ok}")), vec![PiiKind::NationalId]);
1608 let bad = (0..10)
1609 .map(|d| format!("{base}{d}"))
1610 .find(|s| !verhoeff_valid(s))
1611 .unwrap();
1612 assert_eq!(kinds(&format!("Aadhaar {bad}")), vec![]);
1613 }
1614
1615 #[test]
1616 fn replacement_modes_and_pseudonym_consistency() {
1617 let opts = RedactionOptions {
1618 replacement: Replacement::Pseudonym,
1619 return_mapping: true,
1620 ..Default::default()
1621 };
1622 let d = detector(&opts);
1623 let mut r = Redactor::new(&opts, &d);
1624 let mut a = "a@x.com wrote to b@x.com; A@X.COM again".to_string();
1625 r.redact_text(&mut a);
1626 assert_eq!(a, "[EMAIL_1] wrote to [EMAIL_2]; [EMAIL_1] again");
1627 let mut b = "call 555-123-4567 or 555 123 4567".to_string();
1628 r.redact_text(&mut b);
1629 assert_eq!(b, "call [PHONE_1] or [PHONE_1]");
1630 let report = r.finish();
1631 assert_eq!(report.counts["EMAIL"], 3);
1632 assert_eq!(report.counts["PHONE"], 2);
1633 assert_eq!(report.total, 5);
1634 let map = report.mapping.unwrap();
1635 assert!(map.contains(&("a@x.com".into(), "[EMAIL_1]".into())));
1636 assert_eq!(map.len(), 5);
1637
1638 let opts = RedactionOptions {
1639 replacement: Replacement::Fixed("***".into()),
1640 ..Default::default()
1641 };
1642 let d = detector(&opts);
1643 let mut s = "a@x.com".to_string();
1644 Redactor::new(&opts, &d).redact_text(&mut s);
1645 assert_eq!(s, "***");
1646 }
1647
1648 #[test]
1649 fn kinds_filter_custom_patterns_deny_and_allow_terms() {
1650 let opts = RedactionOptions {
1651 kinds: vec![PiiKind::Phone],
1652 ..Default::default()
1653 };
1654 let d = detector(&opts);
1655 let mut s = "a@x.com 555-123-4567".to_string();
1656 Redactor::new(&opts, &d).redact_text(&mut s);
1657 assert_eq!(s, "a@x.com [PHONE]");
1658
1659 let opts = RedactionOptions {
1660 custom_patterns: vec![CustomPattern::parse("case_id=CASE-\\d{4}").unwrap()],
1661 deny_terms: vec!["Project Falcon".into()],
1662 allow_terms: vec!["support@example.com".into()],
1663 ..Default::default()
1664 };
1665 let d = detector(&opts);
1666 let mut s = "CASE-0042 by project falcon: support@example.com / x@example.com".to_string();
1667 Redactor::new(&opts, &d).redact_text(&mut s);
1668 assert_eq!(s, "[CASE_ID] by [REDACTED]: support@example.com / [EMAIL]");
1669 assert!(PatternDetector::new(&RedactionOptions {
1670 custom_patterns: vec![CustomPattern {
1671 name: "bad".into(),
1672 regex: "(".into()
1673 }],
1674 ..Default::default()
1675 })
1676 .is_err());
1677 }
1678
1679 #[test]
1682 fn postal_codes_citations_record_ids_and_part_numbers_are_not_pii() {
1683 let opts = RedactionOptions {
1684 kinds: vec![
1685 PiiKind::Phone,
1686 PiiKind::CreditCard,
1687 PiiKind::Iban,
1688 PiiKind::NationalId,
1689 PiiKind::IpAddress,
1690 ],
1691 ..Default::default()
1692 };
1693 let d = detector(&opts);
1694 let untouched = [
1695 "Ship to: Example Corp, 100 Main Street, Springfield, IL 62704-1234.",
1696 "Send comments to Department 55A, Mail Station 1234 5600 Elm Road.",
1697 "Leave accrual is defined in rule 412-18-205 of the state code.",
1698 "Leave accrual is defined in rule 412-18-0150 of the state code.",
1699 "The input file contains rows such as 60601 0001 12500 00350 and 60602 0002 9800 00120.",
1700 "Replace the bracket with part number TR482913 B before assembly.",
1701 "/XY123456 A configuration file is required for every job.",
1702 "Position created in 2019 95753514 for the regional office.",
1703 "Employee 88061052 76665755 Clerk Grade 4",
1704 ];
1705 for text in untouched {
1706 let mut s = text.to_string();
1707 Redactor::new(&opts, &d).redact_text(&mut s);
1708 assert_eq!(s, text);
1709 }
1710 let mut s = "Call the help desk at +1 555 010 0199 for assistance.".to_string();
1711 Redactor::new(&opts, &d).redact_text(&mut s);
1712 assert_eq!(s, "Call the help desk at [PHONE] for assistance.");
1713 }
1714
1715 #[test]
1718 fn real_values_are_still_redacted() {
1719 assert_eq!(kinds("+1 555 010 0199"), vec![PiiKind::Phone]);
1720 assert_eq!(kinds("(555) 010-0199"), vec![PiiKind::Phone]);
1721 assert_eq!(kinds("555-010-0199"), vec![PiiKind::Phone]);
1722 assert_eq!(kinds("Tel: 020 7946 0958"), vec![PiiKind::Phone]);
1723 assert_eq!(kinds("020 7946 0958"), vec![PiiKind::Phone]);
1724 assert_eq!(kinds("mobile 0151 496 0000"), vec![PiiKind::Phone]);
1725 assert_eq!(kinds("phone: 1234 5600"), vec![PiiKind::Phone]);
1726 assert_eq!(kinds("Mail Station 1234 5600"), vec![]);
1727 assert_eq!(kinds("card 4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1728 assert_eq!(kinds("amex 378282246310005"), vec![PiiKind::CreditCard]);
1729 assert_eq!(kinds("amex 3782 822463 10005"), vec![PiiKind::CreditCard]);
1730 assert_eq!(kinds("mc 5555 5555 5555 4444"), vec![PiiKind::CreditCard]);
1731 assert_eq!(kinds("mc 2223 0031 2200 3222"), vec![PiiKind::CreditCard]);
1732 assert_eq!(kinds("order 8806105276665755"), vec![]);
1734 assert_eq!(
1736 kinds(
1737 "4111 1111 1111 1111"
1738 .replace(' ', "-")
1739 .replace("1111-1111-1111", "11111111-1111")
1740 .as_str()
1741 ),
1742 vec![]
1743 );
1744 assert_eq!(kinds("NINO AB123456C"), vec![PiiKind::NationalId]);
1745 assert_eq!(kinds("NINO AB 12 34 56 C"), vec![PiiKind::NationalId]);
1746 assert_eq!(kinds("NINO AB123456 C"), vec![]);
1747 assert_eq!(kinds("NINO AB 123456C"), vec![]);
1748 assert_eq!(kinds("SSN 123-45-6789"), vec![PiiKind::NationalId]);
1749 assert_eq!(kinds("ID 412-18-0150 on file"), vec![PiiKind::NationalId]);
1750 assert_eq!(kinds("see § 412-18-0150 and section 412-18-0151"), vec![]);
1751 assert_eq!(
1752 kinds("IBAN GB82 WEST 1234 5698 7654 32"),
1753 vec![PiiKind::Iban]
1754 );
1755 let base = "22345678901";
1757 let ok = (0..10)
1758 .map(|d| format!("{base}{d}"))
1759 .find(|s| verhoeff_valid(s))
1760 .unwrap();
1761 let spaced = format!("{} {} {}", &ok[..4], &ok[4..8], &ok[8..]);
1762 assert_eq!(
1763 kinds(&format!("Aadhaar {spaced}")),
1764 vec![PiiKind::NationalId]
1765 );
1766 assert_eq!(kinds(&format!("Aadhaar {} {}", &ok[..4], &ok[4..])), vec![]);
1767 let year_base = "20199575351";
1768 let year_ok = (0..10)
1769 .map(|d| format!("{year_base}{d}"))
1770 .find(|s| verhoeff_valid(s))
1771 .unwrap();
1772 assert_eq!(
1773 kinds(&format!(
1774 "in {} {} {}",
1775 &year_ok[..4],
1776 &year_ok[4..8],
1777 &year_ok[8..]
1778 )),
1779 vec![]
1780 );
1781 assert_eq!(
1782 kinds(&format!("Aadhaar {year_ok}")),
1783 vec![PiiKind::NationalId]
1784 );
1785 }
1786
1787 #[test]
1789 fn matches_are_whole_tokens() {
1790 assert_eq!(redact("IL 62704-1234 stays"), "IL 62704-1234 stays");
1791 assert_eq!(
1792 redact("row 60602 0002 9800 00120 stays"),
1793 "row 60602 0002 9800 00120 stays"
1794 );
1795 assert_eq!(redact("id x555-010-0199"), "id x555-010-0199");
1796 assert_eq!(redact("id 555-010-0199x"), "id 555-010-0199x");
1797 assert_eq!(redact("ref 4111111111111111a"), "ref 4111111111111111a");
1798 assert_eq!(redact("ref AB123456CD"), "ref AB123456CD");
1799 assert_eq!(redact("call 555-010-0199."), "call [PHONE].");
1800 }
1801
1802 #[test]
1804 fn allow_patterns_exempt_matching_spans() {
1805 let opts = RedactionOptions {
1806 allow_patterns: vec![r"\+1 555 010 \d{4}".into(), r"[a-z]+@example\.com".into()],
1807 ..Default::default()
1808 };
1809 let d = detector(&opts);
1810 let mut s =
1811 "+1 555 010 0199, +1 555 010 0200, +1 555 011 0200, bob@example.com, bob@example.org"
1812 .to_string();
1813 Redactor::new(&opts, &d).redact_text(&mut s);
1814 assert_eq!(
1815 s,
1816 "+1 555 010 0199, +1 555 010 0200, [PHONE], bob@example.com, [EMAIL]"
1817 );
1818 assert!(PatternDetector::new(&RedactionOptions {
1819 allow_patterns: vec!["(".into()],
1820 ..Default::default()
1821 })
1822 .is_err());
1823 }
1824
1825 #[test]
1829 fn transcript_timings_are_not_phones_and_cues_count_once() {
1830 fn track(start: f64, end: f64, cue: &str) -> Node {
1831 Node::Track {
1832 track: crate::tree::TreeTrack {
1833 start_time: start,
1834 end_time: end,
1835 identifier: None,
1836 voice: None,
1837 },
1838 cue: cue.to_string(),
1839 inner: Box::new(Node::Paragraph {
1840 text: format!("[time: {start}-{end}] {cue}"),
1841 }),
1842 }
1843 }
1844 let opts = RedactionOptions {
1845 replacement: Replacement::Pseudonym,
1846 ..Default::default()
1847 };
1848 let d = detector(&opts);
1849 let mut doc = DoclingDocument::new("t");
1850 doc.nodes = vec![
1851 track(1234.5, 1240.8, "Shakespeare on scenery by Oscar Wilde."),
1852 track(
1853 1240.8,
1854 1251.25,
1855 "Call me on 555-010-0199 or 555-010-0199 again.",
1856 ),
1857 ];
1858 let report = doc.redact(&opts, &d);
1859 assert_eq!(report.counts.get("PHONE"), Some(&2));
1860 assert_eq!(report.total, 2);
1861 let texts: Vec<(String, String)> = doc
1862 .nodes
1863 .iter()
1864 .map(|n| match n {
1865 Node::Track { cue, inner, .. } => match inner.as_ref() {
1866 Node::Paragraph { text, .. } => (cue.clone(), text.clone()),
1867 _ => unreachable!(),
1868 },
1869 _ => unreachable!(),
1870 })
1871 .collect();
1872 assert_eq!(
1873 texts[0],
1874 (
1875 "Shakespeare on scenery by Oscar Wilde.".to_string(),
1876 "[time: 1234.5-1240.8] Shakespeare on scenery by Oscar Wilde.".to_string()
1877 )
1878 );
1879 assert_eq!(
1880 texts[1],
1881 (
1882 "Call me on [PHONE_1] or [PHONE_1] again.".to_string(),
1883 "[time: 1240.8-1251.25] Call me on [PHONE_1] or [PHONE_1] again.".to_string()
1884 )
1885 );
1886 assert_eq!(kinds("[time: 1234.5-1240.8]"), vec![]);
1888 }
1889
1890 #[test]
1891 fn overlapping_spans_keep_the_longest() {
1892 assert_eq!(kinds("4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1894 let opts = RedactionOptions {
1896 custom_patterns: vec![CustomPattern::parse("contact=Contact: \\S+").unwrap()],
1897 ..Default::default()
1898 };
1899 let d = detector(&opts);
1900 let mut s = "Contact: a@x.com now".to_string();
1901 Redactor::new(&opts, &d).redact_text(&mut s);
1902 assert_eq!(s, "[CONTACT] now");
1903 }
1904
1905 #[test]
1906 fn inline_runs_are_matched_on_the_joined_text() {
1907 let opts = RedactionOptions::default();
1908 let d = detector(&opts);
1909 let mut runs = vec![
1910 InlineRun {
1911 text: "Mail john".into(),
1912 bold: true,
1913 ..Default::default()
1914 },
1915 InlineRun {
1916 text: "@example".into(),
1917 ..Default::default()
1918 },
1919 InlineRun {
1920 text: ".com today, or 555-".into(),
1921 italic: true,
1922 ..Default::default()
1923 },
1924 InlineRun {
1925 text: "123-4567.".into(),
1926 ..Default::default()
1927 },
1928 ];
1929 Redactor::new(&opts, &d).redact_runs(&mut runs);
1930 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1931 assert_eq!(texts, vec!["Mail [EMAIL]", "", " today, or [PHONE]", "."]);
1932 assert!(runs[0].bold && runs[2].italic);
1933 }
1934
1935 #[test]
1936 fn the_document_walk_reaches_every_string() {
1937 use crate::tree::TreeKind;
1938 let mut doc = DoclingDocument::new("file.docx");
1939 doc.push(Node::Heading {
1940 level: 1,
1941 text: "Re: a@x.com".into(),
1942 });
1943 doc.push(Node::Group {
1944 label: "section".into(),
1945 name: Some("b@x.com".into()),
1946 layer: None,
1947 children: vec![Node::Furniture {
1948 layer: crate::ContentLayer::Furniture,
1949 inner: Box::new(Node::Paragraph {
1950 text: "c@x.com".into(),
1951 }),
1952 }],
1953 });
1954 let mut table = Table {
1955 rows: vec![vec!["d@x.com".into()]],
1956 ..Default::default()
1957 };
1958 table.cell_blocks = Some(vec![vec![vec![Node::Paragraph {
1959 text: "e@x.com".into(),
1960 }]]]);
1961 doc.push(Node::Table(table));
1962 doc.push(Node::Picture {
1963 caption: Some("f@x.com".into()),
1964 caption_href: Some("mailto:f@x.com".into()),
1965 image: Some(crate::PictureImage {
1966 mimetype: "image/png".into(),
1967 width: 1,
1968 height: 1,
1969 data: vec![1],
1970 dpi: 72,
1971 }),
1972 classification: None,
1973 description: Some(crate::PictureDescription {
1974 text: "ocr j@x.com".into(),
1975 provenance: "ppocr".into(),
1976 }),
1977 caption_parent: Default::default(),
1978 caption_location: None,
1979 });
1980 doc.push(Node::Formula {
1981 latex: "x@y.com".into(),
1982 orig: "g@x.com".into(),
1983 location: None,
1984 });
1985 doc.links.push(("h@x.com".into(), "mailto:h@x.com".into()));
1986 let mut tree = ItemTree::default();
1987 tree.add(
1988 None,
1989 None,
1990 TreeKind::Text {
1991 label: "text".into(),
1992 text: "i@x.com".into(),
1993 orig: Some("i@x.com".into()),
1994 formatting: None,
1995 hyperlink: Some("mailto:i@x.com".into()),
1996 level: None,
1997 list: None,
1998 },
1999 );
2000 doc.tree = Some(tree);
2001 doc.page_images.insert(
2002 1,
2003 crate::PictureImage {
2004 mimetype: "image/png".into(),
2005 width: 1,
2006 height: 1,
2007 data: vec![1],
2008 dpi: 72,
2009 },
2010 );
2011
2012 let opts = RedactionOptions::default();
2013 let d = detector(&opts);
2014 let report = doc.redact(&opts, &d);
2015 assert_eq!(report.counts["EMAIL"], 11);
2017 let json = doc.export_to_json();
2018 let md = doc.export_to_markdown();
2019 let dl = doc.export_to_doclang();
2020 for out in [&json, &md, &dl] {
2021 assert!(!out.contains("@x.com"), "{out}");
2022 }
2023 assert_eq!(doc.name, "file.docx", "the file name is not touched");
2024 assert!(doc.page_images.is_empty());
2025 assert!(matches!(&doc.nodes[3], Node::Picture { image: None, .. }));
2026 assert!(matches!(&doc.nodes[4], Node::Formula { latex, .. } if latex == "x@y.com"));
2028
2029 let mut doc2 = DoclingDocument::new("t");
2031 doc2.page_images.insert(
2032 1,
2033 crate::PictureImage {
2034 mimetype: "image/png".into(),
2035 width: 1,
2036 height: 1,
2037 data: vec![1],
2038 dpi: 72,
2039 },
2040 );
2041 let opts = RedactionOptions {
2042 images: ImageRedaction::Keep,
2043 ..Default::default()
2044 };
2045 let d = detector(&opts);
2046 doc2.redact(&opts, &d);
2047 assert_eq!(doc2.page_images.len(), 1);
2048 }
2049
2050 #[test]
2051 fn wire_spellings_parse() {
2052 assert_eq!(PiiKind::parse("Credit-Card"), Some(PiiKind::CreditCard));
2053 assert_eq!(PiiKind::parse("ip"), Some(PiiKind::IpAddress));
2054 assert_eq!(PiiKind::parse("nope"), None);
2055 assert_eq!(
2056 Replacement::parse("fixed:XXX"),
2057 Some(Replacement::Fixed("XXX".into()))
2058 );
2059 assert_eq!(
2060 Replacement::parse("Pseudonym"),
2061 Some(Replacement::Pseudonym)
2062 );
2063 assert_eq!(
2064 ImageRedaction::parse("box-out"),
2065 Some(ImageRedaction::BoxOut)
2066 );
2067 let p = CustomPattern::parse("case_id=CASE-\\d+").unwrap();
2068 assert_eq!(
2069 (p.name.as_str(), p.regex.as_str()),
2070 ("case_id", "CASE-\\d+")
2071 );
2072 assert_eq!(CustomPattern::parse("(?i)x=y").unwrap().name, "custom");
2073 }
2074}