Files
pdfcraft/crates/redact/src/patterns.rs
T
Brandon ThomasandClaude Opus 5.5 bd57377435 Fix #525 (item 4): phone pattern redacts international numbers whole (#543)
The Phone Numbers redaction pattern accepted only 10 digits (11 with a
country code), the North American form. For "+49 2151 123456" (12 digits)
the `+` candidate was rejected and the scan then matched "2151 123456",
leaving "+49" visible: a partial redaction in a document the user thinks
is clean.

The pattern now accepts, still grouped by at least one separator:
- E.164 numbers with a leading `+`: 8-15 digits in all;
- national numbers with a trunk 0 followed by 6-14 digits, whose first
  group has at least two digits (so decimals don't match) and which are
  not a day-first date (left to the date pattern);
- North American numbers as before.
A separator may now be followed by `(`, so "+49 (0) 2151 123456" is one
match instead of a partial "(0) 2151 123456".

Refs #525. The other items (page-number and date tokens, stamps) are not
addressed here.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:43:11 -04:00

323 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Search & Redact patterns (Acrobat's Find Text ▸ Patterns): phone numbers, email addresses,
//! credit card numbers, US Social Security numbers and dates. Each matcher works on a page's
//! text as characters and returns character ranges; callers map them to glyphs and areas.
use std::ops::Range;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Pattern {
Phone,
Email,
CreditCard,
Ssn,
Date,
}
pub const PATTERNS: [Pattern; 5] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date];
impl Pattern {
pub fn label(self) -> &'static str {
match self {
Pattern::Phone => "Phone Numbers",
Pattern::Email => "Email Addresses",
Pattern::CreditCard => "Credit Cards",
Pattern::Ssn => "Social Security Numbers",
Pattern::Date => "Dates",
}
}
pub fn id(self) -> &'static str {
match self {
Pattern::Phone => "phone",
Pattern::Email => "email",
Pattern::CreditCard => "credit-card",
Pattern::Ssn => "ssn",
Pattern::Date => "date",
}
}
pub fn from_id(id: &str) -> Option<Self> {
PATTERNS.into_iter().find(|p| p.id() == id)
}
}
fn digit(c: Option<&char>) -> bool {
c.is_some_and(char::is_ascii_digit)
}
fn word(c: Option<&char>) -> bool {
c.is_some_and(|c| c.is_alphanumeric())
}
/// Digits and the separators allowed between them, from `start`: (end, digit count).
fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, usize) {
let (mut i, mut n) = (start, 0);
while i < s.len() && i - start < max_len {
let c = s[i];
if c.is_ascii_digit() {
n += 1;
} else if !(seps.contains(&c) && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) || (c == '(' || c == ')') && seps.contains(&c)) {
break;
}
i += 1;
}
// Never end on a separator.
while i > start && !s[i - 1].is_ascii_digit() {
i -= 1;
}
(i, n)
}
fn phone(s: &[char], i: usize) -> Option<usize> {
let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit();
if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') {
return None;
}
let plus = s[i] == '+';
let first = if plus { i + 1 } else { i };
let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28);
let run = s.get(first..end)?;
let seps = run.iter().filter(|c| !c.is_ascii_digit()).count();
// Always grouped by at least one separator, so plain numbers never match.
if seps == 0 || digit(s.get(end)) {
return None;
}
let ok = if plus {
// E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456").
(8..=15).contains(&n)
} else {
// North American: 10 digits, 11 with a country code ("(555) 123-4567").
let nanp = (10..=11).contains(&n);
// National with a trunk 0 and 6–14 more digits ("0211 123456", "01 23 45 67 89").
// The first group needs two digits so decimals ("0.1234567") don't match, and
// day-first dates ("01.10.2026") are left to the date pattern.
let mut digits = run.iter().skip_while(|c| !c.is_ascii_digit());
let trunk_zero = digits.clone().next() == Some(&'0');
let lead = digits.by_ref().take_while(|c| c.is_ascii_digit()).count();
let trunk = trunk_zero && (7..=15).contains(&n) && lead >= 2 && date(s, i).is_none();
nanp || trunk
};
ok.then_some(end - i)
}
fn email(s: &[char], i: usize) -> Option<usize> {
let local = |c: &char| c.is_ascii_alphanumeric() || "._%+-".contains(*c);
if !s[i].is_ascii_alphanumeric() || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
return None;
}
let mut j = i;
while j < s.len() && local(&s[j]) {
j += 1;
}
if s.get(j) != Some(&'@') {
return None;
}
let d0 = j + 1;
let mut k = d0;
while k < s.len() && (s[k].is_ascii_alphanumeric() || s[k] == '-' || s[k] == '.' && s.get(k + 1).is_some_and(char::is_ascii_alphanumeric)) {
k += 1;
}
let domain: String = s[d0..k].iter().collect();
let tld = domain.rsplit('.').next().unwrap_or("");
(domain.contains('.') && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())).then_some(k - i)
}
fn luhn(digits: &[u32]) -> bool {
let sum: u32 = digits
.iter()
.rev()
.enumerate()
.map(|(k, d)| {
if k % 2 == 1 {
let x = d * 2;
if x > 9 { x - 9 } else { x }
} else {
*d
}
})
.sum();
sum.is_multiple_of(10)
}
fn credit_card(s: &[char], i: usize) -> Option<usize> {
if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let (end, n) = digit_run(s, i, &[' ', '-'], 23);
if !(13..=19).contains(&n) || digit(s.get(end)) {
return None;
}
let digits: Vec<u32> = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect();
luhn(&digits).then_some(end - i)
}
fn ssn(s: &[char], i: usize) -> Option<usize> {
if digit(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let g = |from: usize, n: usize| (from..from + n).all(|k| digit(s.get(k)));
let sep = |k: usize| matches!(s.get(k), Some('-' | ' '));
if !(g(i, 3) && sep(i + 3) && g(i + 4, 2) && sep(i + 6) && g(i + 7, 4)) || digit(s.get(i + 11)) {
return None;
}
let area: String = s[i..i + 3].iter().collect();
(area != "000" && area != "666" && !area.starts_with('9')).then_some(11)
}
const MONTHS: [&str; 12] = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
/// A month name or its three-letter abbreviation (optionally with a full stop) at `i`.
fn month(s: &[char], i: usize) -> Option<usize> {
if word(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let rest: String = s[i..s.len().min(i + 10)].iter().collect::<String>().to_lowercase();
for m in MONTHS {
for cand in [m, &m[..3]] {
if rest.starts_with(cand) && !rest[cand.len()..].starts_with(|c: char| c.is_alphabetic()) {
let mut n = cand.chars().count();
if cand.len() == 3 && s.get(i + n) == Some(&'.') {
n += 1;
}
return Some(n);
}
}
}
None
}
fn number(s: &[char], i: usize, min: usize, max: usize) -> Option<usize> {
let n = s[i.min(s.len())..].iter().take(max + 1).take_while(|c| c.is_ascii_digit()).count();
(n >= min && n <= max).then_some(n)
}
fn date(s: &[char], i: usize) -> Option<usize> {
if word(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
let spaces = |k: usize| s[k.min(s.len())..].iter().take_while(|c| **c == ' ').count();
// Numeric: m/d/yy(yy), m-d-yyyy, d.m.yyyy, yyyy-mm-dd.
if let Some(a) = number(s, i, 1, 4) {
let sep = s.get(i + a).copied();
if matches!(sep, Some('/' | '-' | '.'))
&& let Some(b) = number(s, i + a + 1, 1, 2)
&& s.get(i + a + 1 + b).copied() == sep
&& let Some(c) = number(s, i + a + b + 2, if a == 4 { 1 } else { 2 }, if a == 4 { 2 } else { 4 })
&& (a <= 2 || a == 4)
&& !digit(s.get(i + a + b + 2 + c))
{
return Some(a + b + c + 2);
}
// "5 January 2024".
if a <= 2
&& let k = i + a + spaces(i + a)
&& k > i + a
&& let Some(m) = month(s, k)
{
let k2 = k + m + spaces(k + m);
if let Some(y) = number(s, k2, 4, 4) {
return Some(k2 + y - i);
}
}
return None;
}
// "January 5, 2024" / "Jan. 5 2024".
let m = month(s, i)?;
let k = i + m + spaces(i + m);
let d = number(s, k, 1, 2)?;
let mut k2 = k + d;
if s.get(k2) == Some(&',') {
k2 += 1;
}
k2 += spaces(k2);
let y = number(s, k2, 4, 4)?;
Some(k2 + y - i)
}
/// Character ranges of `text` matching `pattern`.
pub fn find(pattern: Pattern, text: &[char]) -> Vec<Range<usize>> {
let f: fn(&[char], usize) -> Option<usize> = match pattern {
Pattern::Phone => phone,
Pattern::Email => email,
Pattern::CreditCard => credit_card,
Pattern::Ssn => ssn,
Pattern::Date => date,
};
let mut out = Vec::new();
let mut i = 0;
while i < text.len() {
match f(text, i) {
Some(n) if n > 0 => {
out.push(i..i + n);
i += n;
}
_ => i += 1,
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn found(p: Pattern, s: &str) -> Vec<String> {
let c: Vec<char> = s.chars().collect();
find(p, &c).into_iter().map(|r| c[r].iter().collect()).collect()
}
#[test]
fn phone_numbers() {
assert_eq!(
found(Pattern::Phone, "Call (555) 123-4567 or 555.987.6543, +1 555 222 3333; not 12345 or 5551234567890"),
["(555) 123-4567", "555.987.6543", "+1 555 222 3333"]
);
// #525: international numbers match whole; "+49" used to stay visible.
assert_eq!(found(Pattern::Phone, "Tel. +49 2151 123456."), ["+49 2151 123456"]);
assert_eq!(
found(Pattern::Phone, "+44 20 7946 0018, +33 1 23 45 67 89, +49 (0) 2151 123456, (0211) 123456, 030 12345678, 01 23 45 67 89"),
["+44 20 7946 0018", "+33 1 23 45 67 89", "+49 (0) 2151 123456", "(0211) 123456", "030 12345678", "01 23 45 67 89"]
);
// Not phone numbers: day-first dates, decimals, unseparated, too short or too long.
assert_eq!(
found(
Pattern::Phone,
"on 01.10.2026 or 09-10-2026, ratio 0.1234567, id 0211123456, +4921511234567, +1 234 567, 0211 12, +49 1234 5678 9012 3456"
),
Vec::<String>::new()
);
}
#[test]
fn emails() {
assert_eq!(
found(Pattern::Email, "Write to ada.lovelace+pdf@example.co.uk, or bob@host (no TLD) or x@y.z."),
["ada.lovelace+pdf@example.co.uk"]
);
}
#[test]
fn credit_cards_need_a_valid_checksum() {
assert_eq!(
found(Pattern::CreditCard, "Visa 4111 1111 1111 1111, bad 4111 1111 1111 1112, amex 3782-822463-10005"),
["4111 1111 1111 1111", "3782-822463-10005"]
);
}
#[test]
fn social_security_numbers() {
assert_eq!(
found(Pattern::Ssn, "SSN 123-45-6789 and 123 45 6789; invalid 000-12-3456, 666-12-3456, 912-12-3456, 1234-56-7890"),
["123-45-6789", "123 45 6789"]
);
}
#[test]
fn dates() {
assert_eq!(
found(Pattern::Date, "Due 10/01/2026, 2026-10-01, 1.10.26, March 5, 2024, Jan. 7 2025 and 5 June 2023; not 10/2026 or Mayday 12"),
["10/01/2026", "2026-10-01", "1.10.26", "March 5, 2024", "Jan. 7 2025", "5 June 2023"]
);
}
}