Phase 2b of the DLP and mail flow rules spec: every identifier in the §2.3 catalog, each implemented from its issuer's published rules and tested against published examples. US (SSN, ITIN, EIN, ABA routing, driver's licenses, MBI, NPI, DEA), UK (NI number, NHS number, UTR), Canada (SIN), Australia (TFN, Medicare), the EU (Germany's tax ID and ID card, France's NIR, Spain's DNI/NIE, Italy's codice fiscale, the Dutch BSN, Belgium's national number, Poland's PESEL, Sweden's personnummer, Denmark's CPR, Finland's HETU, Ireland's PPS, Portugal's NIF, Austria's SVNR), Norway, Switzerland, India (Aadhaar, PAN), China, Japan, Singapore, South Korea, Brazil (CPF, CNPJ), Mexico (CURP) and South Africa. 49 detectors in all, plus seven templates named for what they find. An identifier that is only digits and whose check about one random number in ten passes counts alone only in its written form (536-22-1234, 943 476 5919) and as bare digits only beside a word; ABA routing numbers and NPIs always need one. Spec §2.3 records this. A test runs every detector over an ordinary business email (order, invoice and tracking numbers, dates, amounts, an address) and requires nothing to fire but the contact detectors. 47 unit tests.
117 lines
3.2 KiB
Rust
117 lines
3.2 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! European identifiers outside the EU (§2.3): Norway's national identity
|
|
//! number and Switzerland's AHV number.
|
|
|
|
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date};
|
|
use regex::Regex;
|
|
use std::sync::LazyLock;
|
|
|
|
pub static DETECTORS: &[Detector] = &[
|
|
Detector::new(
|
|
"no-fnr",
|
|
"Norway: national identity number",
|
|
Region::Europe,
|
|
Strength::Checked,
|
|
no_fnr,
|
|
),
|
|
Detector::new(
|
|
"ch-ahv",
|
|
"Switzerland: AHV number",
|
|
Region::Europe,
|
|
Strength::Checked,
|
|
ch_ahv,
|
|
),
|
|
];
|
|
|
|
static ELEVEN: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\b\d{6} ?\d{5}\b").expect("detector pattern"));
|
|
|
|
/// Two mod 11 check digits over a birth date (D-numbers add 40 to the day,
|
|
/// H-numbers 40 to the month): strong enough to count alone.
|
|
pub fn fnr_valid(n: &str) -> bool {
|
|
let d = digit_values(n);
|
|
if d.len() != 11 {
|
|
return false;
|
|
}
|
|
let check =
|
|
|weights: &[u32]| match 11 - d.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
|
|
11 => Some(0),
|
|
10 => None,
|
|
c => Some(c),
|
|
};
|
|
let day = d[0] * 10 + d[1];
|
|
let month = d[2] * 10 + d[3];
|
|
let day = if day > 40 { day - 40 } else { day };
|
|
let month = if month > 40 { month - 40 } else { month };
|
|
valid_short_date(d[4] * 10 + d[5], month, day)
|
|
&& check(&[3, 7, 6, 1, 8, 9, 4, 5, 2]) == Some(d[9])
|
|
&& check(&[5, 4, 3, 2, 7, 6, 5, 4, 3, 2]) == Some(d[10])
|
|
}
|
|
|
|
fn no_fnr(text: &str, findings: &mut Findings) {
|
|
for m in ELEVEN.find_iter(text) {
|
|
let n = m.as_str().replace(' ', "");
|
|
if fnr_valid(&n) {
|
|
findings.insert(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `756.1234.5678.97`: the country prefix, then an EAN-13 check digit.
|
|
static AHV: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"\b756[. ]?\d{4}[. ]?\d{4}[. ]?\d{2}\b").expect("detector pattern")
|
|
});
|
|
|
|
pub fn ean13_valid(n: &str) -> bool {
|
|
let d = digit_values(n);
|
|
if d.len() != 13 {
|
|
return false;
|
|
}
|
|
let sum: u32 = d[..12]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, x)| if i % 2 == 0 { *x } else { x * 3 })
|
|
.sum();
|
|
(10 - sum % 10) % 10 == d[12]
|
|
}
|
|
|
|
fn ch_ahv(text: &str, findings: &mut Findings) {
|
|
for m in AHV.find_iter(text) {
|
|
let n: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
|
|
if ean13_valid(&n) {
|
|
findings.insert(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::mailflow::detectors::by_id;
|
|
|
|
fn count(id: &str, text: &str) -> usize {
|
|
by_id(id).unwrap().count(text)
|
|
}
|
|
|
|
#[test]
|
|
fn norway() {
|
|
assert_eq!(count("no-fnr", "01019000083"), 1);
|
|
assert_eq!(count("no-fnr", "010190 00083"), 1);
|
|
assert_eq!(count("no-fnr", "01019000084"), 0);
|
|
// Not a date
|
|
assert_eq!(count("no-fnr", "32019000083"), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn switzerland() {
|
|
// The federal example
|
|
assert_eq!(count("ch-ahv", "AHV 756.9217.0769.85"), 1);
|
|
assert_eq!(count("ch-ahv", "7569217076985"), 1);
|
|
assert_eq!(count("ch-ahv", "756.9217.0769.86"), 0);
|
|
}
|
|
}
|