use std::net::{Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use std::sync::OnceLock;
use regex::Regex;
use super::Detection;
use crate::error::Result;
pub const KNOWN_CATEGORIES: &[&str] = &[
"person",
"email",
"phone",
"ipv4",
"ipv6",
"mac",
"url_userinfo",
"date",
"credit_card",
"iban",
"secret",
"pin",
"password",
"otp",
"account_number",
"custom",
"sg_nric",
"ae_eid",
"mrn",
"us_ssn",
"us_itin",
"aba_routing",
"in_gstin",
"in_pan",
];
pub fn category_is_validated(category: &str) -> bool {
matches!(
category,
"email"
| "ipv4"
| "ipv6"
| "mac"
| "url_userinfo"
| "credit_card"
| "iban"
| "sg_nric"
| "ae_eid"
| "us_ssn"
| "us_itin"
| "aba_routing"
| "in_gstin"
| "in_pan"
)
}
fn re(cell: &'static OnceLock<Regex>, pattern: &str) -> &'static Regex {
cell.get_or_init(|| Regex::new(pattern).expect("static detector regex"))
}
fn det(start: usize, end: usize, category: &str, detector: &str) -> Detection {
Detection {
start,
end,
category: category.to_string(),
confidence: 1.0,
detector: detector.to_string(),
}
}
fn boundary_ok(text: &str, start: usize, end: usize) -> bool {
let before = text[..start].chars().next_back();
let after = text[end..].chars().next();
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
}
pub(super) fn run_tier0(
text: &str,
custom_terms: &[String],
known_identities: &[String],
term_sets: &std::collections::BTreeMap<String, Vec<String>>,
) -> Result<Vec<Detection>> {
let mut out = Vec::new();
detect_email(text, &mut out);
detect_phone(text, &mut out);
detect_ipv4(text, &mut out);
detect_ipv6(text, &mut out);
detect_mac(text, &mut out);
detect_url_userinfo(text, &mut out);
detect_date(text, &mut out);
detect_credit_card(text, &mut out);
detect_iban(text, &mut out);
detect_sg_nric(text, &mut out);
detect_ae_eid(text, &mut out);
detect_us_ssn(text, &mut out);
detect_us_itin(text, &mut out);
detect_aba_routing(text, &mut out);
detect_in_gstin(text, &mut out);
detect_in_pan(text, &mut out);
detect_mrn(text, &mut out);
detect_secret(text, &mut out);
detect_keyword_proximity(text, &mut out);
detect_terms(text, custom_terms, "custom", "tier0.dictionary", &mut out)?;
for (category, terms) in term_sets {
detect_terms(text, terms, category, "tier0.term_set", &mut out)?;
}
let identity_terms = identity_match_terms(known_identities);
detect_terms(text, &identity_terms, "person", "tier0.known_identity", &mut out)?;
Ok(out)
}
fn detect_email(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}");
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) {
out.push(det(m.start(), m.end(), "email", "tier0.email"));
}
}
}
fn detect_phone(text: &str, out: &mut Vec<Detection>) {
static INTL: OnceLock<Regex> = OnceLock::new();
static NANP: OnceLock<Regex> = OnceLock::new();
static DASHED: OnceLock<Regex> = OnceLock::new();
static ISO_SHAPE: OnceLock<Regex> = OnceLock::new();
let iso_shape = re(&ISO_SHAPE, r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$");
let candidates = [
re(&INTL, r"\+[1-9][0-9 ().\-]{5,18}[0-9]"),
re(&NANP, r"\(?[0-9]{3}\)?[ .\-][0-9]{3}[ .\-][0-9]{4}"),
re(&DASHED, r"[0-9]{2,4}-[0-9]{2,4}-[0-9]{2,8}"),
];
for r in candidates {
for m in r.find_iter(text) {
if iso_shape.is_match(m.as_str()) {
continue;
}
let digits = m.as_str().chars().filter(char::is_ascii_digit).count();
if (7..=15).contains(&digits) && boundary_ok(text, m.start(), m.end()) {
out.push(det(m.start(), m.end(), "phone", "tier0.phone"));
}
}
}
}
fn detect_ipv4(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}");
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end())
&& !text[m.end()..].starts_with('.')
&& Ipv4Addr::from_str(m.as_str()).is_ok()
{
out.push(det(m.start(), m.end(), "ipv4", "tier0.ipv4"));
}
}
}
fn detect_ipv6(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[0-9A-Fa-f:]{4,45}");
for m in re.find_iter(text) {
let s = m.as_str();
let colons = s.matches(':').count();
if !s.chars().any(|c| c.is_ascii_digit()) || (colons < 4 && !s.contains("::")) {
continue;
}
if boundary_ok(text, m.start(), m.end()) && Ipv6Addr::from_str(s).is_ok() {
out.push(det(m.start(), m.end(), "ipv6", "tier0.ipv6"));
}
}
}
fn detect_mac(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}");
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end())
&& !text[..m.start()].ends_with(':')
&& !text[m.end()..].starts_with(':')
{
out.push(det(m.start(), m.end(), "mac", "tier0.mac"));
}
}
}
fn detect_url_userinfo(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[A-Za-z][A-Za-z0-9+.\-]*://[^/\s@]+@[^\s]+");
for m in re.find_iter(text) {
out.push(det(m.start(), m.end(), "url_userinfo", "tier0.url_userinfo"));
}
}
fn detect_date(text: &str, out: &mut Vec<Detection>) {
static ISO: OnceLock<Regex> = OnceLock::new();
static SLASH: OnceLock<Regex> = OnceLock::new();
let iso = re(&ISO, r"[0-9]{4}-[0-9]{2}-[0-9]{2}");
for m in iso.find_iter(text) {
let parts: Vec<u32> = m.as_str().split('-').map(|p| p.parse().unwrap_or(0)).collect();
if boundary_ok(text, m.start(), m.end())
&& (1..=12).contains(&parts[1])
&& (1..=31).contains(&parts[2])
{
out.push(det(m.start(), m.end(), "date", "tier0.date"));
}
}
let slash = re(&SLASH, r"[0-9]{1,2}/[0-9]{1,2}/[0-9]{2,4}");
for m in slash.find_iter(text) {
let parts: Vec<u32> = m.as_str().split('/').map(|p| p.parse().unwrap_or(0)).collect();
let plausible_dm = (1..=12).contains(&parts[0]) || (1..=12).contains(&parts[1]);
let in_range =
(1..=31).contains(&parts[0]) && (1..=31).contains(&parts[1]) && plausible_dm;
if in_range && boundary_ok(text, m.start(), m.end()) {
out.push(det(m.start(), m.end(), "date", "tier0.date"));
}
}
}
fn luhn_ok(digits: &[u8]) -> bool {
let mut sum = 0u32;
for (i, d) in digits.iter().rev().enumerate() {
let mut v = u32::from(*d);
if i % 2 == 1 {
v *= 2;
if v > 9 {
v -= 9;
}
}
sum += v;
}
sum.is_multiple_of(10)
}
fn detect_credit_card(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[0-9](?:[ \-]?[0-9]){12,18}");
for m in re.find_iter(text) {
let digits: Vec<u8> =
m.as_str().chars().filter_map(|c| c.to_digit(10).map(|d| d as u8)).collect();
if (13..=19).contains(&digits.len())
&& luhn_ok(&digits)
&& boundary_ok(text, m.start(), m.end())
{
out.push(det(m.start(), m.end(), "credit_card", "tier0.credit_card"));
}
}
}
fn iban_mod97_ok(s: &str) -> bool {
let rearranged: String = format!("{}{}", &s[4..], &s[..4]);
let mut rem: u32 = 0;
for c in rearranged.chars() {
let v = match c {
'0'..='9' => c as u32 - '0' as u32,
'A'..='Z' => c as u32 - 'A' as u32 + 10,
'a'..='z' => c as u32 - 'a' as u32 + 10,
_ => return false,
};
rem = if v < 10 { (rem * 10 + v) % 97 } else { (rem * 100 + v) % 97 };
}
rem == 1
}
fn detect_iban(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[A-Z]{2}[0-9]{2}[A-Za-z0-9]{11,30}");
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && iban_mod97_ok(m.as_str()) {
out.push(det(m.start(), m.end(), "iban", "tier0.iban"));
}
}
}
fn detect_sg_nric(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"[STFGMstfgm][0-9]{7}[A-Za-z]");
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && sg_nric_ok(m.as_str()) {
out.push(det(m.start(), m.end(), "sg_nric", "tier0.sg_nric"));
}
}
}
fn sg_nric_ok(s: &str) -> bool {
let up = s.to_ascii_uppercase();
let b = up.as_bytes();
if b.len() != 9 {
return false;
}
const W: [u32; 7] = [2, 7, 6, 5, 4, 3, 2];
let mut sum: u32 = 0;
for i in 0..7 {
let d = (b[1 + i] as char).to_digit(10);
match d {
Some(v) => sum += v * W[i],
None => return false,
}
}
let (offset, table): (u32, &[u8; 11]) = match b[0] {
b'S' => (0, b"JZIHGFEDCBA"),
b'T' => (4, b"JZIHGFEDCBA"),
b'F' => (0, b"XWUTRQPNMLK"),
b'G' => (4, b"XWUTRQPNMLK"),
b'M' => (3, b"KLJNPQRTUWX"),
_ => return false,
};
table[((sum + offset) % 11) as usize] == b[8]
}
fn detect_ae_eid(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(&RE, r"784[- ]?[0-9]{4}[- ]?[0-9]{7}[- ]?[0-9]");
for m in re.find_iter(text) {
let digits: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
let vals: Vec<u8> = digits
.chars()
.filter_map(|c| c.to_digit(10).map(|d| d as u8))
.collect();
if vals.len() == 15 && boundary_ok(text, m.start(), m.end()) && luhn_ok(&vals) {
out.push(det(m.start(), m.end(), "ae_eid", "tier0.ae_eid"));
}
}
}
fn cue_ends(text: &str, cell: &'static OnceLock<Regex>, pattern: &str) -> Vec<usize> {
re(cell, pattern).find_iter(text).map(|m| m.end()).collect()
}
fn cue_near(cues: &[usize], start: usize) -> bool {
cues.iter()
.any(|c| start >= *c && start.saturating_sub(*c) <= 40)
}
fn us_id_groups(s: &str) -> Option<(u32, u32, u32)> {
let digits: Vec<u32> = s.chars().filter_map(|c| c.to_digit(10)).collect();
if digits.len() != 9 {
return None;
}
let n = |sl: &[u32]| sl.iter().fold(0u32, |a, d| a * 10 + d);
Some((n(&digits[0..3]), n(&digits[3..5]), n(&digits[5..9])))
}
fn detect_us_ssn(text: &str, out: &mut Vec<Detection>) {
static SEP: OnceLock<Regex> = OnceLock::new();
static BARE: OnceLock<Regex> = OnceLock::new();
static CUE: OnceLock<Regex> = OnceLock::new();
let sep = re(&SEP, r"[0-9]{3}[- ][0-9]{2}[- ][0-9]{4}");
for m in sep.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && us_ssn_ok(m.as_str()) {
out.push(det(m.start(), m.end(), "us_ssn", "tier0.us_ssn"));
}
}
let cues = cue_ends(
text,
&CUE,
r"(?i)\b(ssn|ss#|social security (number|no\.?)|soc sec|taxpayer id|tin)\b",
);
if cues.is_empty() {
return;
}
let bare = re(&BARE, r"[0-9]{9}");
for m in bare.find_iter(text) {
if boundary_ok(text, m.start(), m.end())
&& cue_near(&cues, m.start())
&& us_ssn_ok(m.as_str())
{
out.push(det(m.start(), m.end(), "us_ssn", "tier0.us_ssn"));
}
}
}
fn us_ssn_ok(s: &str) -> bool {
match us_id_groups(s) {
Some((area, group, serial)) => {
area != 0 && area != 666 && area < 900 && group != 0 && serial != 0
}
None => false,
}
}
fn detect_us_itin(text: &str, out: &mut Vec<Detection>) {
static SEP: OnceLock<Regex> = OnceLock::new();
static BARE: OnceLock<Regex> = OnceLock::new();
static CUE: OnceLock<Regex> = OnceLock::new();
let sep = re(&SEP, r"9[0-9]{2}[- ][0-9]{2}[- ][0-9]{4}");
for m in sep.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && us_itin_ok(m.as_str()) {
out.push(det(m.start(), m.end(), "us_itin", "tier0.us_itin"));
}
}
let cues = cue_ends(
text,
&CUE,
r"(?i)\b(itin|individual taxpayer identification( number)?)\b",
);
if cues.is_empty() {
return;
}
let bare = re(&BARE, r"9[0-9]{8}");
for m in bare.find_iter(text) {
if boundary_ok(text, m.start(), m.end())
&& cue_near(&cues, m.start())
&& us_itin_ok(m.as_str())
{
out.push(det(m.start(), m.end(), "us_itin", "tier0.us_itin"));
}
}
}
fn us_itin_ok(s: &str) -> bool {
match us_id_groups(s) {
Some((area, group, _serial)) => {
(900..=999).contains(&area)
&& matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99)
}
None => false,
}
}
fn detect_aba_routing(text: &str, out: &mut Vec<Detection>) {
static NUM: OnceLock<Regex> = OnceLock::new();
static CUE: OnceLock<Regex> = OnceLock::new();
let cues = cue_ends(
text,
&CUE,
r"(?i)\b(routing|aba|rtn|routing transit)\b",
);
if cues.is_empty() {
return;
}
let num = re(&NUM, r"[0-9]{9}");
for m in num.find_iter(text) {
if boundary_ok(text, m.start(), m.end())
&& cue_near(&cues, m.start())
&& aba_routing_ok(m.as_str())
{
out.push(det(m.start(), m.end(), "aba_routing", "tier0.aba_routing"));
}
}
}
fn aba_routing_ok(s: &str) -> bool {
let d: Vec<u32> = s.chars().filter_map(|c| c.to_digit(10)).collect();
if d.len() != 9 {
return false;
}
let prefix = d[0] * 10 + d[1];
let prefix_ok =
matches!(prefix, 0..=12 | 21..=32 | 61..=72 | 80);
if !prefix_ok {
return false;
}
let sum = 3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8]);
sum.is_multiple_of(10)
}
fn detect_in_gstin(text: &str, out: &mut Vec<Detection>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = re(
&RE,
r"[0-9]{2}[A-Za-z]{5}[0-9]{4}[A-Za-z][1-9A-Za-z][Zz][0-9A-Za-z]",
);
for m in re.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && in_gstin_ok(m.as_str()) {
out.push(det(m.start(), m.end(), "in_gstin", "tier0.in_gstin"));
}
}
}
fn in_gstin_ok(s: &str) -> bool {
const ALPHABET: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
let up = s.to_ascii_uppercase();
let b = up.as_bytes();
if b.len() != 15 {
return false;
}
let state = match (
(b[0] as char).to_digit(10),
(b[1] as char).to_digit(10),
) {
(Some(t), Some(u)) => t * 10 + u,
_ => return false,
};
if !matches!(state, 1..=38 | 97 | 99) {
return false;
}
let mut sum: u32 = 0;
for (i, c) in b[..14].iter().enumerate() {
let Some(v) = (*c as char).to_digit(36) else {
return false;
};
let p = v * if i % 2 == 0 { 1 } else { 2 };
sum += p / 36 + p % 36;
}
ALPHABET[((36 - sum % 36) % 36) as usize] == b[14]
}
fn detect_in_pan(text: &str, out: &mut Vec<Detection>) {
static NUM: OnceLock<Regex> = OnceLock::new();
static CUE: OnceLock<Regex> = OnceLock::new();
let cues = cue_ends(
text,
&CUE,
r"(?i)\b(permanent account number|income tax pan|pan no\.?|pan)\b",
);
if cues.is_empty() {
return;
}
let num = re(
&NUM,
r"[A-Za-z]{3}[PCHFATBLJGpchfatbljg][A-Za-z][0-9]{4}[A-Za-z]",
);
for m in num.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) && cue_near(&cues, m.start()) {
out.push(det(m.start(), m.end(), "in_pan", "tier0.in_pan"));
}
}
}
fn detect_mrn(text: &str, out: &mut Vec<Detection>) {
static CUE: OnceLock<Regex> = OnceLock::new();
static NUM: OnceLock<Regex> = OnceLock::new();
let cue = re(
&CUE,
r"(?i)\b(mrn|medical record (no\.?|number)|patient (id|number)|chart (no\.?|number)|hospital number)\b",
);
let num = re(&NUM, r"[A-Za-z]{0,3}[0-9]{6,12}");
let cues: Vec<usize> = cue.find_iter(text).map(|m| m.end()).collect();
if cues.is_empty() {
return;
}
for m in num.find_iter(text) {
if !boundary_ok(text, m.start(), m.end()) {
continue;
}
let near = cues
.iter()
.any(|c| m.start() >= *c && m.start().saturating_sub(*c) <= 40);
if near {
out.push(det(m.start(), m.end(), "mrn", "tier0.mrn"));
}
}
}
fn shannon_bits_per_byte(s: &str) -> f64 {
let bytes = s.as_bytes();
let mut counts = [0u32; 256];
for b in bytes {
counts[*b as usize] += 1;
}
let n = bytes.len() as f64;
counts
.iter()
.filter(|c| **c > 0)
.map(|c| {
let p = f64::from(*c) / n;
-p * p.log2()
})
.sum()
}
fn detect_secret(text: &str, out: &mut Vec<Detection>) {
static PREFIXED: OnceLock<Regex> = OnceLock::new();
static PEM: OnceLock<Regex> = OnceLock::new();
static ENTROPY: OnceLock<Regex> = OnceLock::new();
let prefixed = re(
&PREFIXED,
r"(sk-[A-Za-z0-9_\-]{16,}|sk_live_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[bpasr]-[A-Za-z0-9\-]{10,}|AIza[0-9A-Za-z_\-]{30,})",
);
for m in prefixed.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) {
out.push(det(m.start(), m.end(), "secret", "tier0.secret_prefix"));
}
}
let pem = re(
&PEM,
r"-----BEGIN [A-Z ]*PRIVATE KEY-----(?s:.*?)(-----END [A-Z ]*PRIVATE KEY-----|\z)",
);
for m in pem.find_iter(text) {
out.push(det(m.start(), m.end(), "secret", "tier0.secret_pem"));
}
let entropy = re(&ENTROPY, r"[A-Za-z0-9+/=_\-]{24,}");
for m in entropy.find_iter(text) {
let s = m.as_str();
let is_grain_hash = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit());
let mixed = s.chars().any(|c| c.is_ascii_uppercase())
&& s.chars().any(|c| c.is_ascii_lowercase())
&& s.chars().any(|c| c.is_ascii_digit());
if !is_grain_hash
&& mixed
&& shannon_bits_per_byte(s) >= 3.7
&& boundary_ok(text, m.start(), m.end())
{
out.push(det(m.start(), m.end(), "secret", "tier0.secret_entropy"));
}
}
}
fn detect_keyword_proximity(text: &str, out: &mut Vec<Detection>) {
static PIN: OnceLock<Regex> = OnceLock::new();
static OTP: OnceLock<Regex> = OnceLock::new();
static PASSWORD: OnceLock<Regex> = OnceLock::new();
static ACCOUNT: OnceLock<Regex> = OnceLock::new();
const FILLER: &str = r#"(?:\s|is|was|are|be|my|the|your|number|no\.?|code|:|=|#|"|')"#;
let rules: [(&Regex, &str, &str); 4] = [
(
re(&PIN, &format!(r"(?i)\b(?:pin|passcode){FILLER}{{0,6}}([0-9]{{4,8}})\b")),
"pin",
"tier0.kw_pin",
),
(
re(
&OTP,
&format!(r"(?i)\b(?:otp|one[ \-]time (?:code|password)){FILLER}{{0,6}}([0-9]{{4,10}})\b"),
),
"otp",
"tier0.kw_otp",
),
(
re(
&PASSWORD,
&format!(r#"(?i)\b(?:password|passphrase|pwd){FILLER}{{0,6}}([^\s"']{{4,64}})"#),
),
"password",
"tier0.kw_password",
),
(
re(
&ACCOUNT,
&format!(r"(?i)\b(?:account|acct\.?){FILLER}{{0,6}}([A-Za-z0-9\-]{{6,20}})\b"),
),
"account_number",
"tier0.kw_account",
),
];
for (rule, category, detector) in rules {
for c in rule.captures_iter(text) {
let m = c.get(1).expect("value group");
if category == "account_number"
&& m.as_str().chars().filter(char::is_ascii_digit).count() < 4
{
continue;
}
let mut end = m.end();
if category == "password" {
let trimmed = m.as_str().trim_end_matches(['.', ',', ';', ':', '!', '?', ')']);
end = m.start() + trimmed.len();
if trimmed.len() < 4 {
continue;
}
}
out.push(det(m.start(), end, category, detector));
}
}
}
fn identity_match_terms(identities: &[String]) -> Vec<String> {
let mut terms = Vec::new();
for id in identities {
let id = id.trim();
if id.len() >= 3 {
terms.push(id.to_string());
}
if let Some((_, tail)) = id.rsplit_once(':') {
let tail = tail.trim();
if tail.len() >= 3 {
terms.push(tail.to_string());
}
}
}
terms
}
pub(super) fn run_known(
text: &str,
known: &[super::KnownIdentity],
) -> Result<Vec<Detection>> {
let mut by_category: std::collections::BTreeMap<&str, Vec<String>> =
std::collections::BTreeMap::new();
for k in known {
let value = k.value.trim();
if value.len() >= 3 {
by_category.entry(k.category.as_str()).or_default().push(value.to_string());
}
}
let mut out = Vec::new();
for (category, terms) in by_category {
detect_terms(text, &terms, category, "tier0.policy_known", &mut out)?;
}
Ok(out)
}
fn detect_terms(
text: &str,
terms: &[String],
category: &str,
detector: &str,
out: &mut Vec<Detection>,
) -> Result<()> {
for term in terms {
let term = term.trim();
if term.is_empty() {
continue;
}
let pattern = format!("(?i){}", regex::escape(term));
let rx = Regex::new(&pattern).map_err(|e| {
crate::error::AreevError::Validation(format!(
"invalid anonymization policy: term '{term}' does not compile: {e}"
))
})?;
for m in rx.find_iter(text) {
if boundary_ok(text, m.start(), m.end()) {
out.push(det(m.start(), m.end(), category, detector));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn cats(text: &str) -> Vec<(String, String)> {
let dets = run_tier0(text, &[], &[], &Default::default()).unwrap();
dets.iter().map(|d| (d.category.clone(), text[d.start..d.end].to_string())).collect()
}
#[test]
fn luhn_and_mod97_gates() {
assert!(luhn_ok(&[4, 5, 3, 9, 1, 4, 8, 8, 0, 3, 4, 3, 6, 4, 6, 7]));
assert!(!luhn_ok(&[4, 5, 3, 9, 1, 4, 8, 8, 0, 3, 4, 3, 6, 4, 6, 8]));
assert!(iban_mod97_ok("GB82WEST12345698765432"));
assert!(!iban_mod97_ok("GB82WEST12345698765433"));
}
#[test]
fn ipv6_skips_rust_paths() {
assert!(cats("use areev_core::anon::scan;").is_empty());
let found = cats("node at fe80::1 responded");
assert_eq!(found, vec![("ipv6".into(), "fe80::1".into())]);
}
#[test]
fn plain_digit_runs_are_not_phones() {
assert!(cats("in 2026 there were 1462 cases").is_empty());
}
}
#[cfg(test)]
mod national_id_tests {
use super::*;
fn cats(text: &str) -> Vec<String> {
let mut out = Vec::new();
run_tier0(text, &[], &[], &Default::default()).unwrap();
let mut d = Vec::new();
detect_sg_nric(text, &mut d);
detect_ae_eid(text, &mut d);
detect_mrn(text, &mut d);
for x in d {
out.push(format!("{}:{}", x.category, &text[x.start..x.end]));
}
out
}
#[test]
fn sg_nric_checksum_gates_the_detection() {
for valid in ["S1234567D", "T1234567J", "F1234567N", "G1234567X", "M1234567X"] {
assert!(sg_nric_ok(valid), "{valid} must validate");
assert_eq!(cats(&format!("nric {valid} on file")).len(), 1, "{valid}");
}
for invalid in ["S1234567A", "T1234567D", "F1234567D", "Z1234567D"] {
assert!(!sg_nric_ok(invalid), "{invalid} must not validate");
assert!(cats(&format!("nric {invalid}")).is_empty(), "{invalid}");
}
}
#[test]
fn sg_nric_is_case_insensitive_but_bounded() {
assert_eq!(cats("s1234567d").len(), 1);
assert!(cats("XS1234567D9").is_empty());
}
#[test]
fn ae_eid_needs_the_issuer_prefix_and_luhn() {
let base = "78419851234567";
let vals: Vec<u8> = base.chars().map(|c| c.to_digit(10).unwrap() as u8).collect();
let check = (0..10u8)
.find(|c| {
let mut v = vals.clone();
v.push(*c);
luhn_ok(&v)
})
.expect("a Luhn check digit always exists");
let eid = format!("784-1985-1234567-{check}");
assert_eq!(cats(&format!("emirates id {eid}")).len(), 1, "{eid}");
let bad = format!("784-1985-1234567-{}", (check + 1) % 10);
assert!(cats(&bad).is_empty(), "{bad} must fail Luhn");
assert!(cats("123456789012345").is_empty(), "no issuer prefix");
}
#[test]
fn mrn_needs_a_cue_word() {
assert_eq!(cats("MRN 00456123 admitted").len(), 1);
assert_eq!(cats("Medical Record Number: 4471820").len(), 1);
assert!(
cats("the ward handled 4471820 samples last year").is_empty(),
"a bare digit run is not an MRN"
);
let far = format!("MRN was not recorded.{} 4471820", " ".repeat(60));
assert!(cats(&far).is_empty(), "proximity window must bound the cue");
}
}
#[cfg(test)]
mod us_id_tests {
use super::*;
fn cats(text: &str) -> Vec<String> {
let mut d = Vec::new();
detect_us_ssn(text, &mut d);
detect_us_itin(text, &mut d);
detect_aba_routing(text, &mut d);
d.iter()
.map(|x| format!("{}:{}", x.category, &text[x.start..x.end]))
.collect()
}
#[test]
fn ssn_separated_forms_need_no_cue() {
assert_eq!(cats("123-45-6789"), vec!["us_ssn:123-45-6789"]);
assert_eq!(cats("123 45 6789"), vec!["us_ssn:123 45 6789"]);
}
#[test]
fn ssn_bare_run_needs_a_cue() {
assert_eq!(cats("SSN 123456789"), vec!["us_ssn:123456789"]);
assert!(cats("bare 123456789").is_empty());
assert!(cats("EBITDA 123456789").is_empty());
}
#[test]
fn ssn_structure_is_validated() {
for bad in [
"000-45-6789",
"666-45-6789",
"900-45-6789",
"123-00-6789",
"123-45-0000",
] {
assert!(cats(bad).is_empty(), "{bad} must not be an SSN");
}
}
#[test]
fn ssn_respects_word_boundaries() {
assert!(cats("X123-45-6789Y").is_empty());
}
#[test]
fn itin_group_ranges_are_validated() {
for ok in ["912-70-1234", "900-50-1234", "999-88-0001", "950-94-1234"] {
assert_eq!(cats(ok), vec![format!("us_itin:{ok}")], "{ok}");
}
for bad in ["912-49-1234", "912-66-1234", "912-89-1234", "912-93-1234"] {
assert!(cats(bad).is_empty(), "{bad} must not be an ITIN");
}
}
#[test]
fn ssn_and_itin_are_disjoint() {
assert!(!us_ssn_ok("912-70-1234"));
assert!(!us_itin_ok("123-45-6789"));
}
#[test]
fn aba_needs_cue_prefix_and_checksum() {
for ok in ["021000021", "011000015", "121000248"] {
assert_eq!(
cats(&format!("routing {ok}")),
vec![format!("aba_routing:{ok}")],
"{ok}"
);
}
assert!(cats("routing 021000022").is_empty());
assert!(aba_checksum_only("130000006"));
assert!(cats("routing 130000006").is_empty());
assert!(cats("invoice 021000021").is_empty());
let far = format!("routing{} 021000021", " ".repeat(45));
assert!(cats(&far).is_empty());
}
fn aba_checksum_only(s: &str) -> bool {
let d: Vec<u32> = s.chars().filter_map(|c| c.to_digit(10)).collect();
(3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8])).is_multiple_of(10)
}
#[test]
fn a_real_phone_is_not_a_us_identifier() {
assert!(cats("(212) 555-0142").is_empty());
let mut d = Vec::new();
detect_phone("(212) 555-0142", &mut d);
assert_eq!(d.len(), 1);
assert_eq!(d[0].category, "phone");
}
#[test]
fn a_dashed_ssn_also_matches_the_phone_shape() {
let mut d = Vec::new();
detect_phone("123-45-6789", &mut d);
assert_eq!(d.len(), 1, "the dashed phone pattern still matches");
}
}
#[cfg(test)]
mod in_tax_id_tests {
use super::*;
fn cats(text: &str) -> Vec<String> {
let mut d = Vec::new();
detect_in_gstin(text, &mut d);
detect_in_pan(text, &mut d);
d.iter()
.map(|x| format!("{}:{}", x.category, &text[x.start..x.end]))
.collect()
}
#[test]
fn gstin_check_character_is_mod_36_weighted_from_the_left() {
assert!(in_gstin_ok("27AAPFU0939F1ZV"));
assert!(!in_gstin_ok("27AAPFU0939F1ZT"));
for ok in ["29AAACI1681G1ZL", "07AAACR5055K1Z9", "33ABCPE1234F2ZH", "97AAAAA0000A1ZV"] {
assert!(in_gstin_ok(ok), "{ok} must validate");
}
}
#[test]
fn a_valid_gstin_is_one_detection_over_all_15_characters() {
let text = "Supplier GSTIN 27AAPFU0939F1ZV";
let mut d = Vec::new();
detect_in_gstin(text, &mut d);
assert_eq!(d.len(), 1);
assert_eq!(d[0].category, "in_gstin");
assert_eq!(d[0].end - d[0].start, 15);
assert_eq!(&text[d[0].start..d[0].end], "27AAPFU0939F1ZV");
assert_eq!(cats("gstin 27aapfu0939f1zv"), vec!["in_gstin:27aapfu0939f1zv"]);
}
#[test]
fn gstin_check_and_state_code_gate_the_detection() {
assert!(cats("27AAPFU0939F1VZ").is_empty());
assert!(cats("27AAPFU0938F1ZV").is_empty());
assert!(cats("00AAPFU0939F1ZV").is_empty());
assert!(!in_gstin_ok("39AAPFU0939F1ZV"));
assert!(!in_gstin_ok("98AAPFU0939F1ZV"));
assert!(cats("X27AAPFU0939F1ZV").is_empty());
assert!(cats("27AAPFU0939F1ZV9").is_empty());
}
#[test]
fn pan_needs_a_cue() {
assert_eq!(cats("PAN: AAPFU0939F"), vec!["in_pan:AAPFU0939F"]);
assert_eq!(cats("PAN No. AAPFU0939F"), vec!["in_pan:AAPFU0939F"]);
assert_eq!(
cats("Permanent Account Number AAPFU0939F"),
vec!["in_pan:AAPFU0939F"]
);
assert_eq!(cats("Income Tax PAN ABCPE1234F"), vec!["in_pan:ABCPE1234F"]);
assert!(cats("SKU AAPFU0939F").is_empty());
let far = format!("PAN{} AAPFU0939F", " ".repeat(45));
assert!(cats(&far).is_empty());
assert!(cats("company AAPFU0939F").is_empty());
}
#[test]
fn pan_holder_type_is_validated() {
assert!(cats("PAN: AAPXU0939F").is_empty());
assert_eq!(cats("PAN: AAXFU0939F"), vec!["in_pan:AAXFU0939F"]);
}
#[test]
fn a_pan_inside_a_gstin_is_covered_by_the_gstin() {
assert_eq!(
cats("PAN / GSTIN 27AAPFU0939F1ZV"),
vec!["in_gstin:27AAPFU0939F1ZV"]
);
}
}