M8: phone pattern finds slashed and labelled unseparated numbers (#3205)

Italian documents write landlines with a slash between area code and
number (0426/123456), and mobiles without separators (3451234567,
+393451234567). The phone pattern needed a separator other than a slash,
so these stayed readable after redaction.

- A slash joins two digits in a number with a trunk-0 area code; a
  North American or + number with a slash still doesn't match, so
  reference numbers like 2024/123456 stay unmarked.
- A number without separators matches only right after a phone label
  (Tel., cell., Fax:, telefono, mobile, ...). Unlabelled runs of digits
  (ids, VAT numbers) still don't match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-18 19:37:27 +08:00
co-authored by Claude Opus 5.5
parent 50f261d70c
commit 73aea6e0ac
2 changed files with 106 additions and 9 deletions
+104 -7
View File
@@ -56,7 +56,12 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize,
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)) {
} else if !(seps.contains(&c)
&& (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'('))
// A slash only ever joins two digits ("0426/123456").
&& (c != '/' || digit(s.get(i + 1)) && digit(i.checked_sub(1).and_then(|p| s.get(p))))
|| (c == '(' || c == ')') && seps.contains(&c))
{
break;
}
i += 1;
@@ -84,8 +89,56 @@ fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool {
}
}
/// Words that label the number after them as a phone number, lowercased ("Tel.", "cell.", "Fax:").
const PHONE_LABELS: [&str; 24] = [
"tel",
"tél",
"tele",
"telefon",
"telefono",
"teléfono",
"telephone",
"téléphone",
"phone",
"ph",
"cell",
"cel",
"cellulare",
"cellphone",
"mobile",
"mob",
"mobil",
"móvil",
"movil",
"handy",
"fax",
"tlf",
"telf",
"gsm",
];
/// Whether the text before `i` is a phone label, with one to four spaces, colons or dots between.
fn labelled(s: &[char], i: usize) -> bool {
let before = |k: usize| k.checked_sub(1).and_then(|p| s.get(p)).copied();
let mut j = i;
while i - j < 4 && before(j).is_some_and(|c| matches!(c, ' ' | ':' | '.' | '\u{a0}')) {
j -= 1;
}
let end = j;
while end - j < 12 && before(j).is_some_and(char::is_alphabetic) {
j -= 1;
}
if end == i || before(j).is_some_and(char::is_alphanumeric) {
return false;
}
let label: String = s.get(j..end).unwrap_or_default().iter().flat_map(|c| c.to_lowercase()).collect();
PHONE_LABELS.contains(&label.as_str())
}
fn phone(s: &[char], i: usize) -> Option<usize> {
const SEPS: [char; 5] = [' ', '-', '.', '(', ')'];
// A slash joins the area code to the number in Italian and German landlines ("0426/123456").
const RUN_SEPS: [char; 6] = [' ', '-', '.', '(', ')', '/'];
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;
@@ -97,20 +150,22 @@ fn phone(s: &[char], i: usize) -> Option<usize> {
}
let plus = s[i] == '+';
let first = if plus { i + 1 } else { i };
let (end, n) = digit_run(s, first, &SEPS, 28);
let (end, n) = digit_run(s, first, &RUN_SEPS, 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; nor does the
// head of a run too long to be a phone number.
if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) {
// Grouped by at least one separator, or labelled ("cell. 3451234567", #3205), so plain
// numbers never match; nor does the head of a run too long to be a phone number.
if seps == 0 && !labelled(s, i) || digit(s.get(end)) || run_continues(s, end.checked_sub(1)?, 1, &SEPS) {
return None;
}
// Only an area code with a trunk 0 takes a slash, so "2024/123456" is no phone number.
let slash = run.contains(&'/');
let ok = if plus {
// E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456").
(8..=15).contains(&n)
!slash && (8..=15).contains(&n)
} else {
// North American: 10 digits, 11 with a country code ("(555) 123-4567").
let nanp = (10..=11).contains(&n);
let nanp = (10..=11).contains(&n) && !slash;
// 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.
@@ -381,6 +436,48 @@ mod tests {
);
}
/// #3205: Italian numbers written with a slash, and, after a phone label, without separators.
#[test]
fn slashed_and_labelled_unseparated_phone_numbers() {
let lines = [
"A tel. 0426 123456",
"B tel. +39 0426 123456",
"C cell. 345 1234567",
"D cell. 3451234567",
"E tel. 0426/123456",
"F cell. 345.123.4567",
"G tel. 0426.123456",
"H cell. +393451234567",
"Fax: 0211123456",
"Telefono 0426/123456",
];
assert_eq!(
found(Pattern::Phone, &lines.join("\n")),
[
"0426 123456",
"+39 0426 123456",
"345 1234567",
"3451234567",
"0426/123456",
"345.123.4567",
"0426.123456",
"+393451234567",
"0211123456",
"0426/123456"
]
);
// Without a label, or after a word that only ends like one, unseparated numbers stay
// unmarked; a slash needs a trunk 0 and digits on both sides, and dates stay dates.
assert_eq!(
found(
Pattern::Phone,
"id 0211123456, +4921511234567, hotel 3451234567, prot. 2024/123456, on 01/10/2026, \
tel. 12345, tel 0426/ 123456, +39 0426/123456"
),
Vec::<String>::new()
);
}
#[test]
fn the_tail_of_a_longer_number_is_not_a_phone_number() {
// #1884: the last groups of a French social security number, an IBAN and a SIRET used
+2 -2
View File
@@ -5608,8 +5608,8 @@ milestone = "M8"
status = "shipped"
commands = ["redact.search"]
tools = ["redact_mark"]
evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"]
notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded"
evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"]
notes = "phone (North American, E.164 with + country code, national with trunk 0, a slash after a trunk-0 area code; numbers without separators only after a phone label such as Tel. or cell.), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded"
[[feature]]
id = "protect.redact-pattern-locales"