Skip to main content

veil_engine/detect/
mod.rs

1//! Detectors find candidate spans; the engine resolves overlaps between them.
2
3mod card;
4mod custom;
5mod dictionary;
6mod email;
7mod iban;
8mod ip;
9mod natid;
10#[cfg(feature = "phone")]
11mod phone;
12mod secret;
13mod url;
14
15use std::sync::Arc;
16
17use crate::kind::EntityKind;
18use crate::rules::{RulesError, RulesFile};
19
20/// Where a match came from.
21#[derive(Clone, Debug, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum Source {
24    /// A [`DictionaryEntry`](crate::DictionaryEntry) in the rules.
25    Dictionary,
26    /// One of the built-in detectors, such as email or card numbers.
27    BuiltIn,
28    /// A [`RegexRule`](crate::RegexRule); holds the rule's name.
29    Regex(Arc<str>),
30    /// The name-detection model.
31    Ner,
32}
33
34#[derive(Clone, Debug)]
35pub(crate) struct Candidate {
36    pub start: usize,
37    pub end: usize,
38    pub kind: EntityKind,
39    pub source: Source,
40    /// Breaks ties between equal-length overlapping spans: lower wins.
41    pub priority: u8,
42}
43
44pub(crate) trait Detector: Send + Sync {
45    fn find(&self, text: &str, out: &mut Vec<Candidate>);
46}
47
48// Priorities: the dictionary beats custom regex rules, which beat built-ins.
49pub(crate) const P_DICTIONARY: u8 = 0;
50pub(crate) const P_CUSTOM: u8 = 5;
51pub(crate) const P_SECRET: u8 = 10;
52pub(crate) const P_CARD: u8 = 11;
53pub(crate) const P_IBAN: u8 = 12;
54pub(crate) const P_NATID: u8 = 13;
55pub(crate) const P_EMAIL: u8 = 14;
56pub(crate) const P_URL: u8 = 15;
57pub(crate) const P_IP: u8 = 16;
58#[cfg(feature = "phone")]
59pub(crate) const P_PHONE: u8 = 17;
60pub(crate) const P_NER: u8 = 20;
61
62pub(crate) fn build(rules: &RulesFile) -> Result<Vec<Box<dyn Detector>>, RulesError> {
63    let d = &rules.detectors;
64    let mut out: Vec<Box<dyn Detector>> = Vec::new();
65    if !rules.dictionary.is_empty() {
66        out.push(Box::new(dictionary::Dictionary::new(&rules.dictionary)?));
67    }
68    for rule in &rules.regex {
69        out.push(Box::new(custom::Custom::new(rule)?));
70    }
71    if d.secret.enabled {
72        out.push(Box::new(secret::Secret));
73    }
74    if d.card.enabled {
75        out.push(Box::new(card::Card));
76    }
77    if d.iban.enabled {
78        out.push(Box::new(iban::Iban));
79    }
80    if d.natid.enabled {
81        out.push(Box::new(natid::NatId::new(&d.natid.options.locales)?));
82    }
83    if d.email.enabled {
84        out.push(Box::new(email::Email));
85    }
86    if d.url.enabled {
87        out.push(Box::new(url::Url));
88    }
89    if d.ip.enabled {
90        out.push(Box::new(ip::Ip { include_private: d.ip.options.include_private }));
91    }
92    if d.phone.enabled {
93        #[cfg(feature = "phone")]
94        out.push(Box::new(phone::Phone::new(&d.phone.options.regions)?));
95        #[cfg(not(feature = "phone"))]
96        return Err(RulesError::NotBuiltIn("phone"));
97    }
98    Ok(out)
99}
100
101/// Keeps the longest span where candidates overlap; on equal length the lower
102/// priority wins. Returns the kept spans in text order.
103pub(crate) fn resolve(mut candidates: Vec<Candidate>) -> Vec<Candidate> {
104    candidates.sort_by(|a, b| {
105        (b.end - b.start).cmp(&(a.end - a.start)).then(a.priority.cmp(&b.priority)).then(a.start.cmp(&b.start))
106    });
107    let mut kept: Vec<Candidate> = Vec::new();
108    for c in candidates {
109        if c.start < c.end && kept.iter().all(|k| c.end <= k.start || c.start >= k.end) {
110            kept.push(c);
111        }
112    }
113    kept.sort_by_key(|k| k.start);
114    kept
115}
116
117/// True if the character before `start` and the one at `end` are not
118/// letters, digits or `_`, i.e. the span is a whole word.
119pub(crate) fn word_bounded(text: &str, start: usize, end: usize) -> bool {
120    let before = text[..start].chars().next_back();
121    let after = text[end..].chars().next();
122    let is_word = |c: char| c.is_alphanumeric() || c == '_';
123    !before.is_some_and(is_word) && !after.is_some_and(is_word)
124}
125
126pub(crate) fn builtin(start: usize, end: usize, kind: &'static str, priority: u8) -> Candidate {
127    Candidate { start, end, kind: EntityKind::builtin(kind), source: Source::BuiltIn, priority }
128}
129
130/// Luhn checksum over a string of ASCII digits.
131pub(crate) fn luhn(digits: &str) -> bool {
132    let mut sum = 0;
133    for (i, b) in digits.bytes().rev().enumerate() {
134        let mut d = u32::from(b - b'0');
135        if i % 2 == 1 {
136            d *= 2;
137            if d > 9 {
138                d -= 9;
139            }
140        }
141        sum += d;
142    }
143    sum % 10 == 0
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn c(start: usize, end: usize, priority: u8) -> Candidate {
151        builtin(start, end, "EMAIL", priority)
152    }
153
154    #[test]
155    fn longest_wins_then_priority() {
156        let kept = resolve(vec![c(0, 5, 1), c(3, 12, 9), c(12, 14, 1), c(12, 14, 0)]);
157        let spans: Vec<_> = kept.iter().map(|k| (k.start, k.end, k.priority)).collect();
158        assert_eq!(spans, [(3, 12, 9), (12, 14, 0)]);
159    }
160
161    #[test]
162    fn luhn_checks() {
163        assert!(luhn("4111111111111111"));
164        assert!(!luhn("4111111111111112"));
165        assert!(luhn("378282246310005"));
166    }
167
168    #[test]
169    fn word_bounds() {
170        assert!(word_bounded("a acme b", 2, 6));
171        assert!(!word_bounded("a acmes b", 2, 6));
172        assert!(word_bounded("acme", 0, 4));
173        assert!(word_bounded("(acme)", 1, 5));
174    }
175}