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 ner_min_score: f32,
288 pub images: ImageRedaction,
289 pub return_mapping: bool,
293}
294
295impl Default for RedactionOptions {
296 fn default() -> Self {
297 Self {
298 kinds: Vec::new(),
299 replacement: Replacement::Label,
300 custom_patterns: Vec::new(),
301 deny_terms: Vec::new(),
302 allow_terms: Vec::new(),
303 ner_min_score: 0.85,
304 images: ImageRedaction::Drop,
305 return_mapping: false,
306 }
307 }
308}
309
310impl RedactionOptions {
311 pub fn wants(&self, kind: PiiKind) -> bool {
313 self.kinds.is_empty() || self.kinds.contains(&kind)
314 }
315
316 pub fn wants_ner(&self) -> bool {
318 PiiKind::NER.iter().any(|&k| self.wants(k))
319 }
320}
321
322#[derive(Debug, Clone, PartialEq)]
326pub struct Span {
327 pub start: usize,
328 pub end: usize,
329 pub kind: PiiKind,
330 pub score: f32,
331 pub name: Option<String>,
332}
333
334impl Span {
335 pub fn new(start: usize, end: usize, kind: PiiKind) -> Self {
336 Span {
337 start,
338 end,
339 kind,
340 score: 1.0,
341 name: None,
342 }
343 }
344}
345
346pub trait PiiDetector {
350 fn detect(&self, text: &str) -> Vec<Span>;
351}
352
353pub struct CompositeDetector(pub Vec<Box<dyn PiiDetector + Send + Sync>>);
356
357impl PiiDetector for CompositeDetector {
358 fn detect(&self, text: &str) -> Vec<Span> {
359 self.0.iter().flat_map(|d| d.detect(text)).collect()
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Default)]
369pub struct RedactionReport {
370 pub counts: BTreeMap<String, usize>,
371 pub total: usize,
372 pub mapping: Option<Vec<(String, String)>>,
373}
374
375impl RedactionReport {
376 pub fn counts_json(&self) -> serde_json::Value {
379 serde_json::Value::Object(
380 self.counts
381 .iter()
382 .map(|(k, v)| (k.clone(), serde_json::json!(v)))
383 .collect(),
384 )
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct RedactionError(pub String);
391
392impl fmt::Display for RedactionError {
393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394 f.write_str(&self.0)
395 }
396}
397
398impl std::error::Error for RedactionError {}
399
400macro_rules! re {
405 ($pat:expr) => {{
406 static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
407 RE.get_or_init(|| Regex::new($pat).expect("static regex"))
408 }};
409}
410
411pub struct PatternDetector {
415 kinds: Vec<PiiKind>,
416 custom: Vec<(String, Regex)>,
417 deny: Vec<Regex>,
418 allow: Vec<String>,
419}
420
421impl PatternDetector {
422 pub fn new(opts: &RedactionOptions) -> Result<Self, RedactionError> {
426 let kinds = PiiKind::PATTERN
427 .into_iter()
428 .filter(|&k| opts.wants(k))
429 .collect();
430 let mut custom = Vec::new();
431 for p in &opts.custom_patterns {
432 let re = Regex::new(&p.regex)
433 .map_err(|e| RedactionError(format!("redact pattern {:?}: {e}", p.name)))?;
434 custom.push((p.name.trim().to_ascii_uppercase().replace('-', "_"), re));
435 }
436 let deny = opts
437 .deny_terms
438 .iter()
439 .filter(|t| !t.trim().is_empty())
440 .map(|t| {
441 Regex::new(&format!(r"(?i)\b{}\b", regex::escape(t.trim()))).expect("escaped term")
442 })
443 .collect();
444 let allow = opts
445 .allow_terms
446 .iter()
447 .map(|t| t.trim().to_lowercase())
448 .filter(|t| !t.is_empty())
449 .collect();
450 Ok(Self {
451 kinds,
452 custom,
453 deny,
454 allow,
455 })
456 }
457
458 fn wants(&self, kind: PiiKind) -> bool {
459 self.kinds.contains(&kind)
460 }
461
462 pub fn allowed(&self, text: &str) -> bool {
464 !self.allow.is_empty() && self.allow.iter().any(|a| a == &text.to_lowercase())
465 }
466}
467
468impl PiiDetector for PatternDetector {
469 fn detect(&self, text: &str) -> Vec<Span> {
470 let mut out = Vec::new();
471 if text.is_empty() {
472 return out;
473 }
474 let mut credentials: Vec<(usize, usize)> = Vec::new();
478 if self.wants(PiiKind::UrlCredentials) {
479 for c in re!(r"(?i)[a-z][a-z0-9+.\-]*://([^\s/@:]+:[^\s/@]+)@").captures_iter(text) {
480 let m = c.get(1).expect("group 1");
481 credentials.push((m.start(), m.end()));
482 out.push(Span::new(m.start(), m.end(), PiiKind::UrlCredentials));
483 }
484 }
485 if self.wants(PiiKind::Email) {
486 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) {
487 let (mut s, mut e) = (m.start(), m.end());
490 while e > s && text.as_bytes()[e - 1] == b'.' {
491 e -= 1;
492 }
493 while s < e && text.as_bytes()[s] == b'\\' {
494 s += 1;
495 }
496 if s < e && !credentials.iter().any(|&(cs, ce)| s < ce && cs < e) {
497 out.push(Span::new(s, e, PiiKind::Email));
498 }
499 }
500 }
501 if self.wants(PiiKind::CreditCard) {
502 for m in re!(r"\b\d(?:[ \-]?\d){12,18}\b").find_iter(text) {
503 let digits: String = m.as_str().chars().filter(|c| c.is_ascii_digit()).collect();
504 if !maximal_digit_run(text, m.start(), m.end()) {
507 continue;
508 }
509 if (13..=19).contains(&digits.len()) && luhn_valid(&digits) {
510 out.push(Span::new(m.start(), m.end(), PiiKind::CreditCard));
511 }
512 }
513 }
514 if self.wants(PiiKind::Iban) {
515 for m in
516 re!(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b").find_iter(text)
517 {
518 let compact: String = m.as_str().chars().filter(|c| *c != ' ').collect();
519 if iban_valid(&compact) {
520 out.push(Span::new(m.start(), m.end(), PiiKind::Iban));
521 }
522 }
523 }
524 if self.wants(PiiKind::IpAddress) {
525 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) {
526 let before = text[..m.start()].chars().next_back();
528 let after = text[m.end()..].chars().next();
529 if before == Some('.') || after == Some('.') {
530 continue;
531 }
532 if matches!(before, Some('v') | Some('V')) {
534 continue;
535 }
536 out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
537 }
538 for m in re!(r"[0-9A-Fa-f:]{3,45}").find_iter(text) {
539 let s = m.as_str();
540 if s.matches(':').count() < 2 || !s.chars().any(|c| c.is_ascii_hexdigit()) {
541 continue;
542 }
543 if s.parse::<std::net::Ipv6Addr>().is_ok() {
544 out.push(Span::new(m.start(), m.end(), PiiKind::IpAddress));
545 }
546 }
547 }
548 if self.wants(PiiKind::NationalId) {
549 for m in re!(r"\b\d{3}-\d{2}-\d{4}\b").find_iter(text) {
551 let s = m.as_str();
552 let area = &s[0..3];
553 if area == "000" || area == "666" || area.starts_with('9') {
554 continue;
555 }
556 if &s[4..6] == "00" || &s[7..11] == "0000" {
557 continue;
558 }
559 out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
560 }
561 for m in re!(r"\b[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z] ?\d{2} ?\d{2} ?\d{2} ?[A-D]\b")
563 .find_iter(text)
564 {
565 let prefix = &m.as_str()[0..2];
566 if ["BG", "GB", "NK", "KN", "TN", "NT", "ZZ"].contains(&prefix) {
567 continue;
568 }
569 out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
570 }
571 for m in re!(r"\b[2-9]\d{3}[ \-]?\d{4}[ \-]?\d{4}\b").find_iter(text) {
573 let digits: String = m.as_str().chars().filter(|c| c.is_ascii_digit()).collect();
574 if verhoeff_valid(&digits) {
575 out.push(Span::new(m.start(), m.end(), PiiKind::NationalId));
576 }
577 }
578 }
579 if self.wants(PiiKind::Phone) {
580 let ssn_shape = re!(r"^\d{3}-\d{2}-\d{4}$");
581 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) {
582 let s = m.as_str();
583 let digits = s.chars().filter(|c| c.is_ascii_digit()).count();
584 if !(7..=15).contains(&digits) || looks_like_date_or_version(s) {
585 continue;
586 }
587 if ssn_shape.is_match(s) {
589 continue;
590 }
591 out.push(Span::new(m.start(), m.end(), PiiKind::Phone));
596 }
597 }
598 for (name, re) in &self.custom {
599 for c in re.captures_iter(text) {
600 let m = c.get(1).or_else(|| c.get(0)).expect("match");
601 if m.start() == m.end() {
602 continue;
603 }
604 out.push(Span {
605 start: m.start(),
606 end: m.end(),
607 kind: PiiKind::Custom,
608 score: 1.0,
609 name: Some(name.clone()),
610 });
611 }
612 }
613 for re in &self.deny {
614 for m in re.find_iter(text) {
615 out.push(Span::new(m.start(), m.end(), PiiKind::Custom));
616 }
617 }
618 out.retain(|s| !self.allowed(&text[s.start..s.end]));
619 out
620 }
621}
622
623fn maximal_digit_run(text: &str, start: usize, end: usize) -> bool {
626 let bytes = text.as_bytes();
627 let before =
628 start >= 2 && matches!(bytes[start - 1], b' ' | b'-') && bytes[start - 2].is_ascii_digit();
629 let after = end + 1 < bytes.len()
630 && matches!(bytes[end], b' ' | b'-')
631 && bytes[end + 1].is_ascii_digit();
632 !before && !after
633}
634
635pub fn luhn_valid(digits: &str) -> bool {
637 let mut sum = 0u32;
638 let mut double = false;
639 for c in digits.chars().rev() {
640 let Some(d) = c.to_digit(10) else {
641 return false;
642 };
643 let d = if double {
644 let x = d * 2;
645 if x > 9 {
646 x - 9
647 } else {
648 x
649 }
650 } else {
651 d
652 };
653 sum += d;
654 double = !double;
655 }
656 digits.len() >= 2 && sum % 10 == 0
657}
658
659fn iban_length(country: &str) -> Option<usize> {
663 Some(match country {
664 "AL" | "AZ" | "CY" | "DO" | "GT" | "HU" | "LB" | "PL" | "BY" | "SV" | "NI" => 28,
665 "AD" | "CZ" | "MD" | "PK" | "RO" | "SA" | "SK" | "ES" | "SE" | "TN" | "VG" => 24,
666 "AT" | "BA" | "EE" | "KZ" | "LT" | "LU" | "XK" | "MN" => 20,
667 "BH" | "BG" | "CR" | "GE" | "DE" | "IE" | "ME" | "RS" | "GB" | "VA" => 22,
668 "BE" | "BI" => 16,
669 "BR" | "PS" | "QA" | "UA" | "EG" => 29,
670 "HR" | "LI" | "CH" => 21,
671 "DK" | "FO" | "FI" | "GL" | "NL" | "SD" | "FK" => 18,
672 "FR" | "GR" | "IT" | "MR" | "MC" | "SM" | "DJ" => 27,
673 "GI" | "IL" | "AE" | "IQ" | "TL" | "SO" | "OM" => 23,
674 "IS" => 26,
675 "JO" | "KW" | "MU" | "YE" => 30,
676 "LV" => 21,
677 "MK" | "SI" => 19,
678 "MT" => 31,
679 "NO" => 15,
680 "PT" | "ST" | "LY" => 25,
681 "TR" => 26,
682 "LC" => 32,
683 "RU" => 33,
684 _ => return None,
685 })
686}
687
688pub fn iban_valid(iban: &str) -> bool {
690 if iban.len() < 15 || iban.len() > 34 || !iban.is_ascii() {
691 return false;
692 }
693 let country = &iban[0..2];
694 if iban_length(country) != Some(iban.len()) {
695 return false;
696 }
697 let rearranged = format!("{}{}", &iban[4..], &iban[..4]);
698 let mut rem = 0u32;
699 for c in rearranged.chars() {
700 let v = match c {
701 '0'..='9' => c as u32 - '0' as u32,
702 'A'..='Z' => c as u32 - 'A' as u32 + 10,
703 _ => return false,
704 };
705 rem = if v >= 10 {
706 (rem * 100 + v) % 97
707 } else {
708 (rem * 10 + v) % 97
709 };
710 }
711 rem == 1
712}
713
714pub fn verhoeff_valid(digits: &str) -> bool {
716 const D: [[u8; 10]; 10] = [
717 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
718 [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
719 [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
720 [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
721 [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
722 [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
723 [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
724 [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
725 [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
726 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
727 ];
728 const P: [[u8; 10]; 8] = [
729 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
730 [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
731 [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
732 [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
733 [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
734 [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
735 [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
736 [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
737 ];
738 let mut c = 0u8;
739 for (i, ch) in digits.chars().rev().enumerate() {
740 let Some(d) = ch.to_digit(10) else {
741 return false;
742 };
743 c = D[c as usize][P[i % 8][d as usize] as usize];
744 }
745 !digits.is_empty() && c == 0
746}
747
748fn looks_like_date_or_version(s: &str) -> bool {
751 let s = s.trim();
752 re!(r"^\d{4}[\-./]\d{1,2}[\-./]\d{1,2}$").is_match(s)
753 || re!(r"^\d{1,2}[\-./]\d{1,2}[\-./]\d{2,4}$").is_match(s)
754 || (s.contains('.') && !s.contains(' ') && !s.contains('-') && !s.starts_with('+'))
756}
757
758pub struct Redactor<'a> {
767 opts: &'a RedactionOptions,
768 detector: &'a dyn PiiDetector,
769 numbers: HashMap<(String, String), usize>,
771 next: HashMap<String, usize>,
772 report: RedactionReport,
773 counting: bool,
777}
778
779impl<'a> Redactor<'a> {
780 pub fn new(opts: &'a RedactionOptions, detector: &'a dyn PiiDetector) -> Self {
781 Redactor {
782 opts,
783 detector,
784 numbers: HashMap::new(),
785 next: HashMap::new(),
786 report: RedactionReport {
787 mapping: opts.return_mapping.then(Vec::new),
788 ..Default::default()
789 },
790 counting: true,
791 }
792 }
793
794 pub fn report(&self) -> &RedactionReport {
796 &self.report
797 }
798
799 pub fn finish(self) -> RedactionReport {
800 self.report
801 }
802
803 fn spans(&self, text: &str) -> Vec<Span> {
808 let mut spans: Vec<Span> = self
809 .detector
810 .detect(text)
811 .into_iter()
812 .filter(|s| s.start < s.end && s.end <= text.len())
813 .filter(|s| text.is_char_boundary(s.start) && text.is_char_boundary(s.end))
814 .filter(|s| self.opts.wants(s.kind))
815 .filter(|s| !PiiKind::NER.contains(&s.kind) || s.score >= self.opts.ner_min_score)
816 .collect();
817 spans.sort_by(|a, b| {
818 a.start
819 .cmp(&b.start)
820 .then((b.end - b.start).cmp(&(a.end - a.start)))
821 .then(a.kind.cmp(&b.kind))
822 });
823 let mut kept: Vec<Span> = Vec::with_capacity(spans.len());
824 for s in spans {
825 match kept.last() {
826 Some(last) if s.start < last.end => {
827 if s.end - s.start > last.end - last.start {
829 kept.pop();
830 kept.push(s);
831 }
832 }
833 _ => kept.push(s),
834 }
835 }
836 kept
837 }
838
839 pub fn has_pii(&self, text: &str) -> bool {
842 !self.spans(text).is_empty()
843 }
844
845 fn label_of(span: &Span) -> String {
846 span.name
847 .clone()
848 .unwrap_or_else(|| span.kind.label().to_string())
849 }
850
851 fn placeholder(&mut self, span: &Span, original: &str) -> String {
853 let label = Self::label_of(span);
854 if self.counting {
855 *self.report.counts.entry(label.clone()).or_insert(0) += 1;
856 self.report.total += 1;
857 }
858 let out = match &self.opts.replacement {
859 Replacement::Label => format!("[{label}]"),
860 Replacement::Fixed(s) => s.clone(),
861 Replacement::Pseudonym => {
862 let key = normalize(original);
865 let n = match self.numbers.get(&(label.clone(), key.clone())) {
866 Some(n) => *n,
867 None => {
868 let next = self.next.entry(label.clone()).or_insert(0);
869 *next += 1;
870 self.numbers.insert((label.clone(), key), *next);
871 *next
872 }
873 };
874 format!("[{label}_{n}]")
875 }
876 };
877 if let Some(map) = self.report.mapping.as_mut() {
878 if !map.iter().any(|(o, _)| o == original) {
879 map.push((original.to_string(), out.clone()));
880 }
881 }
882 out
883 }
884
885 pub fn redact_text(&mut self, text: &mut String) {
887 let spans = self.spans(text);
888 if spans.is_empty() {
889 return;
890 }
891 let mut out = String::with_capacity(text.len());
892 let mut pos = 0;
893 for span in &spans {
894 out.push_str(&text[pos..span.start]);
895 let original = text[span.start..span.end].to_string();
896 out.push_str(&self.placeholder(span, &original));
897 pos = span.end;
898 }
899 out.push_str(&text[pos..]);
900 *text = out;
901 }
902
903 fn redact_opt(&mut self, text: &mut Option<String>) {
904 if let Some(t) = text.as_mut() {
905 self.redact_text(t);
906 }
907 }
908
909 pub fn redact_runs(&mut self, runs: &mut [InlineRun]) {
913 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
914 let spans = self.spans(&joined);
915 if spans.is_empty() {
916 return;
917 }
918 let mut starts = Vec::with_capacity(runs.len());
920 let mut acc = 0;
921 for r in runs.iter() {
922 starts.push(acc);
923 acc += r.text.len();
924 }
925 let mut placeholders: Vec<String> = Vec::with_capacity(spans.len());
929 for span in &spans {
930 let original = joined[span.start..span.end].to_string();
931 placeholders.push(self.placeholder(span, &original));
932 }
933 for (i, run) in runs.iter_mut().enumerate() {
934 let (rs, re) = (starts[i], starts[i] + run.text.len());
935 let mut out = String::new();
936 let mut pos = rs;
937 for (span, ph) in spans.iter().zip(&placeholders) {
938 if span.end <= rs || span.start >= re {
939 continue;
940 }
941 let s = span.start.max(rs);
942 out.push_str(&joined[pos..s]);
943 if span.start >= rs {
944 out.push_str(ph);
945 }
946 pos = span.end.min(re);
947 }
948 out.push_str(&joined[pos..re]);
949 run.text = out;
950 }
951 }
952
953 fn redact_table(&mut self, table: &mut Table) {
954 for row in table.rows.iter_mut() {
955 for cell in row.iter_mut() {
956 self.redact_text(cell);
957 }
958 }
959 if let Some(cells) = table.cells.as_mut() {
960 for c in cells.iter_mut() {
961 self.redact_text(&mut c.text);
962 }
963 }
964 if let Some(blocks) = table.cell_blocks.as_mut() {
965 for row in blocks.iter_mut() {
966 for cell in row.iter_mut() {
967 self.redact_nodes(cell);
968 }
969 }
970 }
971 self.redact_opt(&mut table.caption);
972 }
973
974 fn redact_fields(&mut self, items: &mut [crate::FieldItem]) {
975 for f in items.iter_mut() {
976 self.redact_opt(&mut f.marker);
977 self.redact_opt(&mut f.key);
978 self.redact_opt(&mut f.value);
979 }
980 }
981
982 fn redact_graph(&mut self, cells: &mut [crate::GraphCell]) {
983 for c in cells.iter_mut() {
984 self.redact_text(&mut c.text);
985 self.redact_text(&mut c.orig);
986 }
987 }
988
989 fn redact_image(&mut self, image: &mut Option<crate::PictureImage>) {
990 if self.opts.images == ImageRedaction::Drop {
991 *image = None;
992 }
993 }
994
995 pub fn redact_nodes(&mut self, nodes: &mut [Node]) {
997 for node in nodes.iter_mut() {
998 self.redact_node(node);
999 }
1000 }
1001
1002 fn redact_node(&mut self, node: &mut Node) {
1003 match node {
1004 Node::Heading { text, .. }
1005 | Node::Paragraph { text }
1006 | Node::CheckboxItem { text, .. }
1007 | Node::PageFurniture { text, .. }
1008 | Node::FurnitureText { text, .. }
1009 | Node::TextDump(text) => self.redact_text(text),
1010 Node::ListItem {
1011 text,
1012 marker,
1013 dclx,
1014 href,
1015 ..
1016 } => {
1017 self.redact_text(text);
1018 self.redact_opt(marker);
1019 self.redact_opt(href);
1020 if let Some(d) = dclx.as_mut() {
1021 self.redact_text(&mut d.text);
1022 self.redact_opt(&mut d.marker);
1023 self.redact_runs(&mut d.runs);
1024 }
1025 }
1026 Node::Code {
1027 text, orig, pretty, ..
1028 } => {
1029 self.redact_text(text);
1030 self.redact_opt(orig);
1031 self.redact_opt(pretty);
1032 }
1033 Node::Table(t) => self.redact_table(t),
1034 Node::Picture {
1035 caption,
1036 caption_href,
1037 image,
1038 description,
1039 ..
1040 } => {
1041 self.redact_opt(caption);
1042 self.redact_opt(caption_href);
1043 if let Some(d) = description.as_mut() {
1046 self.redact_text(&mut d.text);
1047 }
1048 self.redact_image(image);
1049 }
1050 Node::Formula { orig, .. } => self.redact_text(orig),
1053 Node::Caption { text, href } | Node::LabeledText { text, href, .. } => {
1054 self.redact_text(text);
1055 self.redact_opt(href);
1056 }
1057 Node::Chart { table, caption, .. } => {
1058 self.redact_table(table);
1059 self.redact_opt(caption);
1060 }
1061 Node::Group { name, children, .. } => {
1062 self.redact_opt(name);
1063 self.redact_nodes(children);
1064 }
1065 Node::FieldRegion { items } => self.redact_fields(items),
1066 Node::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1067 Node::InlineGroup { runs, md_text, .. } => {
1068 self.redact_runs(runs);
1069 self.redact_text(md_text);
1070 }
1071 Node::Furniture { inner, .. }
1072 | Node::Commented { inner, .. }
1073 | Node::Located { inner, .. }
1074 | Node::Prov { inner, .. }
1075 | Node::DoclangOnly(inner) => self.redact_node(inner),
1076 Node::Track { track, cue, inner } => {
1077 self.redact_opt(&mut track.voice);
1078 self.redact_text(cue);
1079 self.redact_node(inner);
1080 }
1081 Node::CommentSection { name, text, .. } => {
1082 self.redact_text(name);
1083 self.redact_text(text);
1084 }
1085 Node::PictureChildren(children) => self.redact_nodes(children),
1086 Node::PageBreak | Node::PageInfo { .. } => {}
1087 }
1088 }
1089
1090 pub fn redact_tree(&mut self, tree: &mut ItemTree) {
1095 let counting = std::mem::replace(&mut self.counting, false);
1096 self.redact_tree_items(tree);
1097 self.counting = counting;
1098 }
1099
1100 fn redact_tree_items(&mut self, tree: &mut ItemTree) {
1101 for item in tree.items.iter_mut() {
1102 match &mut item.kind {
1103 TreeKind::Text {
1104 text,
1105 orig,
1106 hyperlink,
1107 list,
1108 ..
1109 } => {
1110 self.redact_text(text);
1111 self.redact_opt(orig);
1112 self.redact_opt(hyperlink);
1113 if let Some(l) = list.as_mut() {
1114 self.redact_text(&mut l.marker);
1115 }
1116 }
1117 TreeKind::Code {
1118 text,
1119 orig,
1120 hyperlink,
1121 ..
1122 } => {
1123 self.redact_text(text);
1124 self.redact_opt(orig);
1125 self.redact_opt(hyperlink);
1126 }
1127 TreeKind::Group { name, .. } => self.redact_text(name),
1128 TreeKind::Table { table, .. } => self.redact_table(table),
1129 TreeKind::Picture {
1130 image,
1131 chart,
1132 description,
1133 ..
1134 } => {
1135 self.redact_image(image);
1136 if let Some(d) = description.as_mut() {
1137 self.redact_text(&mut d.text);
1138 }
1139 if let Some(t) = chart.as_mut() {
1140 self.redact_table(t);
1141 }
1142 }
1143 TreeKind::FieldRegion { items } => self.redact_fields(items),
1144 TreeKind::KeyValueGraph { cells, .. } => self.redact_graph(cells),
1145 }
1146 if let Some(track) = item.source.as_mut() {
1147 self.redact_opt(&mut track.voice);
1148 }
1149 for note in item.notes.iter_mut() {
1150 self.redact_text(&mut note.text);
1151 }
1152 }
1153 }
1154
1155 pub fn redact_links(&mut self, links: &mut [(String, String)]) {
1157 for (text, href) in links.iter_mut() {
1158 self.redact_text(text);
1159 self.redact_text(href);
1160 }
1161 }
1162
1163 pub fn redact_document(&mut self, doc: &mut DoclingDocument) {
1165 self.redact_nodes(&mut doc.nodes);
1166 if let Some(tree) = doc.tree.as_mut() {
1167 self.redact_tree(tree);
1168 }
1169 self.redact_links(&mut doc.links);
1170 if self.opts.images == ImageRedaction::Drop {
1171 doc.page_images.clear();
1172 }
1173 }
1174}
1175
1176fn normalize(s: &str) -> String {
1180 s.to_lowercase()
1181 .chars()
1182 .filter(|c| !c.is_whitespace() && *c != '-' && *c != '.' && *c != '(' && *c != ')')
1183 .collect()
1184}
1185
1186impl DoclingDocument {
1187 pub fn redact(
1191 &mut self,
1192 opts: &RedactionOptions,
1193 detector: &dyn PiiDetector,
1194 ) -> RedactionReport {
1195 let mut r = Redactor::new(opts, detector);
1196 r.redact_document(self);
1197 r.finish()
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 fn detector(opts: &RedactionOptions) -> PatternDetector {
1206 PatternDetector::new(opts).unwrap()
1207 }
1208
1209 fn redact(text: &str) -> String {
1210 let opts = RedactionOptions::default();
1211 let d = detector(&opts);
1212 let mut s = text.to_string();
1213 Redactor::new(&opts, &d).redact_text(&mut s);
1214 s
1215 }
1216
1217 fn kinds(text: &str) -> Vec<PiiKind> {
1218 let opts = RedactionOptions::default();
1219 let d = detector(&opts);
1220 Redactor::new(&opts, &d)
1221 .spans(text)
1222 .into_iter()
1223 .map(|s| s.kind)
1224 .collect()
1225 }
1226
1227 #[test]
1228 fn emails_including_markdown_links_and_escapes() {
1229 assert_eq!(
1230 redact("Write to [john.doe@example.com](mailto:john.doe@example.com)."),
1231 "Write to [[EMAIL]](mailto:[EMAIL])."
1232 );
1233 assert_eq!(
1235 redact(r"jane\_doe@example.org, please."),
1236 "[EMAIL], please."
1237 );
1238 assert_eq!(redact("a@b"), "a@b");
1239 }
1240
1241 #[test]
1242 fn cards_need_luhn_and_ibans_need_mod97() {
1243 assert_eq!(kinds("card 4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1244 assert_eq!(kinds("card 4111-1111-1111-1111"), vec![PiiKind::CreditCard]);
1245 assert_eq!(kinds("order 4111 1111 1111 1112"), vec![]);
1246 assert_eq!(
1247 kinds("IBAN DE89 3704 0044 0532 0130 00"),
1248 vec![PiiKind::Iban]
1249 );
1250 assert_eq!(kinds("IBAN GB82WEST12345698765432"), vec![PiiKind::Iban]);
1251 assert_eq!(kinds("IBAN DE89 3704 0044 0532 0130 01"), vec![]);
1252 assert_eq!(kinds("code XY12 ABCD 1234 EFGH 5678"), vec![]);
1253 }
1254
1255 #[test]
1256 fn phones_but_not_dates_or_versions() {
1257 assert_eq!(kinds("call +1 (555) 123-4567"), vec![PiiKind::Phone]);
1258 assert_eq!(kinds("call 555-123-4567 today"), vec![PiiKind::Phone]);
1259 assert_eq!(kinds("tel +44 20 7946 0958"), vec![PiiKind::Phone]);
1260 assert_eq!(kinds("on 2024-01-15 and 15.01.2024 and 01/15/24"), vec![]);
1261 assert_eq!(kinds("version 1.2.3 and v10.0.1 and v2.0.0.1"), vec![]);
1262 assert_eq!(kinds("ISBN 978-3-16-148410-0"), vec![]);
1263 }
1264
1265 #[test]
1266 fn ip_addresses_and_url_credentials() {
1267 assert_eq!(kinds("host 192.168.1.10 up"), vec![PiiKind::IpAddress]);
1268 assert_eq!(kinds("v 1.2.3.4.5"), vec![]);
1269 assert_eq!(
1270 kinds("addr 2001:db8::1 and ::1"),
1271 vec![PiiKind::IpAddress, PiiKind::IpAddress]
1272 );
1273 assert_eq!(kinds("at 12:30:45 today"), vec![]);
1274 assert_eq!(kinds("mac aa:bb:cc:dd:ee:ff"), vec![]);
1275 assert_eq!(
1276 redact("see https://alice:s3cret@db.example.com/x"),
1277 "see https://[CREDENTIALS]@db.example.com/x"
1278 );
1279 }
1280
1281 #[test]
1282 fn national_ids_with_their_rules() {
1283 assert_eq!(kinds("SSN 123-45-6789"), vec![PiiKind::NationalId]);
1284 assert_eq!(
1285 kinds("not 000-45-6789 nor 123-00-6789 nor 900-45-6789"),
1286 vec![]
1287 );
1288 assert_eq!(kinds("NINO AB 12 34 56 C"), vec![PiiKind::NationalId]);
1289 assert_eq!(kinds("NINO QQ 12 34 56 C"), vec![]);
1290 assert_eq!(kinds("NINO BG 12 34 56 C"), vec![]);
1291 let base = "22345678901";
1294 let ok = (0..10)
1295 .map(|d| format!("{base}{d}"))
1296 .find(|s| verhoeff_valid(s))
1297 .unwrap();
1298 assert_eq!(kinds(&format!("Aadhaar {ok}")), vec![PiiKind::NationalId]);
1299 let bad = (0..10)
1300 .map(|d| format!("{base}{d}"))
1301 .find(|s| !verhoeff_valid(s))
1302 .unwrap();
1303 assert_eq!(kinds(&format!("Aadhaar {bad}")), vec![]);
1304 }
1305
1306 #[test]
1307 fn replacement_modes_and_pseudonym_consistency() {
1308 let opts = RedactionOptions {
1309 replacement: Replacement::Pseudonym,
1310 return_mapping: true,
1311 ..Default::default()
1312 };
1313 let d = detector(&opts);
1314 let mut r = Redactor::new(&opts, &d);
1315 let mut a = "a@x.com wrote to b@x.com; A@X.COM again".to_string();
1316 r.redact_text(&mut a);
1317 assert_eq!(a, "[EMAIL_1] wrote to [EMAIL_2]; [EMAIL_1] again");
1318 let mut b = "call 555-123-4567 or 555 123 4567".to_string();
1319 r.redact_text(&mut b);
1320 assert_eq!(b, "call [PHONE_1] or [PHONE_1]");
1321 let report = r.finish();
1322 assert_eq!(report.counts["EMAIL"], 3);
1323 assert_eq!(report.counts["PHONE"], 2);
1324 assert_eq!(report.total, 5);
1325 let map = report.mapping.unwrap();
1326 assert!(map.contains(&("a@x.com".into(), "[EMAIL_1]".into())));
1327 assert_eq!(map.len(), 5);
1328
1329 let opts = RedactionOptions {
1330 replacement: Replacement::Fixed("***".into()),
1331 ..Default::default()
1332 };
1333 let d = detector(&opts);
1334 let mut s = "a@x.com".to_string();
1335 Redactor::new(&opts, &d).redact_text(&mut s);
1336 assert_eq!(s, "***");
1337 }
1338
1339 #[test]
1340 fn kinds_filter_custom_patterns_deny_and_allow_terms() {
1341 let opts = RedactionOptions {
1342 kinds: vec![PiiKind::Phone],
1343 ..Default::default()
1344 };
1345 let d = detector(&opts);
1346 let mut s = "a@x.com 555-123-4567".to_string();
1347 Redactor::new(&opts, &d).redact_text(&mut s);
1348 assert_eq!(s, "a@x.com [PHONE]");
1349
1350 let opts = RedactionOptions {
1351 custom_patterns: vec![CustomPattern::parse("case_id=CASE-\\d{4}").unwrap()],
1352 deny_terms: vec!["Project Falcon".into()],
1353 allow_terms: vec!["support@example.com".into()],
1354 ..Default::default()
1355 };
1356 let d = detector(&opts);
1357 let mut s = "CASE-0042 by project falcon: support@example.com / x@example.com".to_string();
1358 Redactor::new(&opts, &d).redact_text(&mut s);
1359 assert_eq!(s, "[CASE_ID] by [REDACTED]: support@example.com / [EMAIL]");
1360 assert!(PatternDetector::new(&RedactionOptions {
1361 custom_patterns: vec![CustomPattern {
1362 name: "bad".into(),
1363 regex: "(".into()
1364 }],
1365 ..Default::default()
1366 })
1367 .is_err());
1368 }
1369
1370 #[test]
1371 fn overlapping_spans_keep_the_longest() {
1372 assert_eq!(kinds("4111 1111 1111 1111"), vec![PiiKind::CreditCard]);
1374 let opts = RedactionOptions {
1376 custom_patterns: vec![CustomPattern::parse("contact=Contact: \\S+").unwrap()],
1377 ..Default::default()
1378 };
1379 let d = detector(&opts);
1380 let mut s = "Contact: a@x.com now".to_string();
1381 Redactor::new(&opts, &d).redact_text(&mut s);
1382 assert_eq!(s, "[CONTACT] now");
1383 }
1384
1385 #[test]
1386 fn inline_runs_are_matched_on_the_joined_text() {
1387 let opts = RedactionOptions::default();
1388 let d = detector(&opts);
1389 let mut runs = vec![
1390 InlineRun {
1391 text: "Mail john".into(),
1392 bold: true,
1393 ..Default::default()
1394 },
1395 InlineRun {
1396 text: "@example".into(),
1397 ..Default::default()
1398 },
1399 InlineRun {
1400 text: ".com today, or 555-".into(),
1401 italic: true,
1402 ..Default::default()
1403 },
1404 InlineRun {
1405 text: "123-4567.".into(),
1406 ..Default::default()
1407 },
1408 ];
1409 Redactor::new(&opts, &d).redact_runs(&mut runs);
1410 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1411 assert_eq!(texts, vec!["Mail [EMAIL]", "", " today, or [PHONE]", "."]);
1412 assert!(runs[0].bold && runs[2].italic);
1413 }
1414
1415 #[test]
1416 fn the_document_walk_reaches_every_string() {
1417 use crate::tree::TreeKind;
1418 let mut doc = DoclingDocument::new("file.docx");
1419 doc.push(Node::Heading {
1420 level: 1,
1421 text: "Re: a@x.com".into(),
1422 });
1423 doc.push(Node::Group {
1424 label: "section".into(),
1425 name: Some("b@x.com".into()),
1426 layer: None,
1427 children: vec![Node::Furniture {
1428 layer: crate::ContentLayer::Furniture,
1429 inner: Box::new(Node::Paragraph {
1430 text: "c@x.com".into(),
1431 }),
1432 }],
1433 });
1434 let mut table = Table {
1435 rows: vec![vec!["d@x.com".into()]],
1436 ..Default::default()
1437 };
1438 table.cell_blocks = Some(vec![vec![vec![Node::Paragraph {
1439 text: "e@x.com".into(),
1440 }]]]);
1441 doc.push(Node::Table(table));
1442 doc.push(Node::Picture {
1443 caption: Some("f@x.com".into()),
1444 caption_href: Some("mailto:f@x.com".into()),
1445 image: Some(crate::PictureImage {
1446 mimetype: "image/png".into(),
1447 width: 1,
1448 height: 1,
1449 data: vec![1],
1450 dpi: 72,
1451 }),
1452 classification: None,
1453 description: Some(crate::PictureDescription {
1454 text: "ocr j@x.com".into(),
1455 provenance: "ppocr".into(),
1456 }),
1457 caption_parent: Default::default(),
1458 caption_location: None,
1459 });
1460 doc.push(Node::Formula {
1461 latex: "x@y.com".into(),
1462 orig: "g@x.com".into(),
1463 location: None,
1464 });
1465 doc.links.push(("h@x.com".into(), "mailto:h@x.com".into()));
1466 let mut tree = ItemTree::default();
1467 tree.add(
1468 None,
1469 None,
1470 TreeKind::Text {
1471 label: "text".into(),
1472 text: "i@x.com".into(),
1473 orig: Some("i@x.com".into()),
1474 formatting: None,
1475 hyperlink: Some("mailto:i@x.com".into()),
1476 level: None,
1477 list: None,
1478 },
1479 );
1480 doc.tree = Some(tree);
1481 doc.page_images.insert(
1482 1,
1483 crate::PictureImage {
1484 mimetype: "image/png".into(),
1485 width: 1,
1486 height: 1,
1487 data: vec![1],
1488 dpi: 72,
1489 },
1490 );
1491
1492 let opts = RedactionOptions::default();
1493 let d = detector(&opts);
1494 let report = doc.redact(&opts, &d);
1495 assert_eq!(report.counts["EMAIL"], 11);
1497 let json = doc.export_to_json();
1498 let md = doc.export_to_markdown();
1499 let dl = doc.export_to_doclang();
1500 for out in [&json, &md, &dl] {
1501 assert!(!out.contains("@x.com"), "{out}");
1502 }
1503 assert_eq!(doc.name, "file.docx", "the file name is not touched");
1504 assert!(doc.page_images.is_empty());
1505 assert!(matches!(&doc.nodes[3], Node::Picture { image: None, .. }));
1506 assert!(matches!(&doc.nodes[4], Node::Formula { latex, .. } if latex == "x@y.com"));
1508
1509 let mut doc2 = DoclingDocument::new("t");
1511 doc2.page_images.insert(
1512 1,
1513 crate::PictureImage {
1514 mimetype: "image/png".into(),
1515 width: 1,
1516 height: 1,
1517 data: vec![1],
1518 dpi: 72,
1519 },
1520 );
1521 let opts = RedactionOptions {
1522 images: ImageRedaction::Keep,
1523 ..Default::default()
1524 };
1525 let d = detector(&opts);
1526 doc2.redact(&opts, &d);
1527 assert_eq!(doc2.page_images.len(), 1);
1528 }
1529
1530 #[test]
1531 fn wire_spellings_parse() {
1532 assert_eq!(PiiKind::parse("Credit-Card"), Some(PiiKind::CreditCard));
1533 assert_eq!(PiiKind::parse("ip"), Some(PiiKind::IpAddress));
1534 assert_eq!(PiiKind::parse("nope"), None);
1535 assert_eq!(
1536 Replacement::parse("fixed:XXX"),
1537 Some(Replacement::Fixed("XXX".into()))
1538 );
1539 assert_eq!(
1540 Replacement::parse("Pseudonym"),
1541 Some(Replacement::Pseudonym)
1542 );
1543 assert_eq!(
1544 ImageRedaction::parse("box-out"),
1545 Some(ImageRedaction::BoxOut)
1546 );
1547 let p = CustomPattern::parse("case_id=CASE-\\d+").unwrap();
1548 assert_eq!(
1549 (p.name.as_str(), p.regex.as_str()),
1550 ("case_id", "CASE-\\d+")
1551 );
1552 assert_eq!(CustomPattern::parse("(?i)x=y").unwrap().name, "custom");
1553 }
1554}