fix(redact): the tail of a longer number is not a phone number (#1884) (#1911)

The phone pattern tried every position, so the last groups of a French
social security number, an IBAN or a SIRET matched as a trunk-0 or NANP
number once the groups before them were skipped, and the start of the
identifier stayed visible. A phone number is now a whole run of digit groups:
one reached through separators from a digit before it, or going on past the
28-character cap, is not one.


Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mtlcl410
2026-10-15 02:29:06 -04:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 6882275ee5
commit 1ae12090d0
+38 -3
View File
@@ -68,18 +68,41 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize,
(i, n)
}
/// Whether the separated digit run goes on past `k` in the direction `step` (1 or −1): the next
/// digit after `k` is reached through separators only.
fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool {
let mut j = k;
loop {
let Some(c) = j.checked_add_signed(step).and_then(|j| s.get(j)) else { return false };
if c.is_ascii_digit() {
return true;
}
if !seps.contains(c) {
return false;
}
j = j.wrapping_add_signed(step);
}
}
fn phone(s: &[char], i: usize) -> Option<usize> {
const SEPS: [char; 5] = [' ', '-', '.', '(', ')'];
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;
}
// A phone number is a whole run of digit groups. The tail of a longer one (an IBAN, a
// company or social security number) is not a phone number, however it is grouped (#1884).
if run_continues(s, i, -1, &SEPS) {
return None;
}
let plus = s[i] == '+';
let first = if plus { i + 1 } else { i };
let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28);
let (end, n) = digit_run(s, first, &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.
if seps == 0 || digit(s.get(end)) {
// 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) {
return None;
}
let ok = if plus {
@@ -358,6 +381,18 @@ mod tests {
);
}
#[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
// to be marked as phone numbers (and the start left visible). Example numbers.
for s in ["NIR 1 85 05 78 006 084 36 end", "IBAN FR76 3000 6000 0112 3456 7890 189", "SIRET 123 456 782 00010"] {
assert_eq!(found(Pattern::Phone, s), Vec::<String>::new(), "{s}");
}
assert_eq!(found(Pattern::Phone, "tel 01 23 45 67 89 / +33 1 23 45 67 89"), ["01 23 45 67 89", "+33 1 23 45 67 89"]);
// Nor the head of a run too long to be one: 28 characters in, this one still goes on.
assert_eq!(found(Pattern::Phone, "01 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7"), Vec::<String>::new());
}
#[test]
fn emails() {
assert_eq!(