From 1ae12090d00e2da9cb6951c3619f579e6ea90dfe Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 15 Oct 2026 14:29:06 +0800 Subject: [PATCH] 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 --- crates/redact/src/patterns.rs | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 0af3b02a9..18fc65e33 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -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 { + 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::::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::::new()); + } + #[test] fn emails() { assert_eq!(