use std::collections::HashSet;
use crate::scripts::detect_scripts;
use crate::zalgo::is_zalgo;
const ZALGO_THRESHOLD: usize = 3;
const MAX_LEET_LEN: usize = 64;
const INVISIBLE: &[char] = &[
'\u{200B}', '\u{200C}', '\u{200D}', '\u{2060}', '\u{2061}', '\u{2062}', '\u{2063}', '\u{FEFF}',
];
const BIDI_OVERRIDE: &[char] = &['\u{202D}', '\u{202E}'];
const BIDI_ISOLATES: &[char] = &['\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
const WRAP: &[char] = &[
'"', '.', ',', ';', ':', '?', '!', '(', ')', '[', ']', '{', '}', '<', '>', '\u{AB}', '\u{BB}',
'\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '`', '\u{2014}', '\u{2026}', '\'', ' ', '\t',
];
const CJK_SCRIPTS: &[&str] = &["Han", "Hiragana", "Katakana", "Hangul", "Bopomofo"];
const UNITS: &[&str] = &[
"kω", "mω", "gω", "µf", "nf", "pf", "µm", "µs", "µg", "µa", "µv", "å", "ω", "°c", "°f",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnomalyKind {
Invisible,
Bidi,
Zalgo,
MixedScript,
BidiMixed,
Leet,
Segmentation,
}
impl AnomalyKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
AnomalyKind::Invisible => "invisible",
AnomalyKind::Bidi => "bidi",
AnomalyKind::Zalgo => "zalgo",
AnomalyKind::MixedScript => "mixed_script",
AnomalyKind::BidiMixed => "bidi_mixed",
AnomalyKind::Leet => "leet",
AnomalyKind::Segmentation => "segmentation",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Finding {
pub kind: AnomalyKind,
pub token: String,
pub start: usize,
pub end: usize,
pub detail: String,
}
impl Finding {
#[must_use]
pub fn reason(&self) -> String {
match self.kind {
AnomalyKind::Invisible => {
format!(
"{:?} contains an invisible character ({})",
self.token, self.detail
)
}
AnomalyKind::Bidi => format!(
"{:?} contains a bidirectional control character ({})",
self.token, self.detail
),
AnomalyKind::Zalgo => {
format!(
"{:?} is overloaded with combining marks (zalgo)",
self.token
)
}
AnomalyKind::MixedScript => format!("{:?} mixes {}", self.token, self.detail),
AnomalyKind::BidiMixed => format!(
"{:?} mixes left-to-right and right-to-left letters ({}), which can visually reorder",
self.token, self.detail
),
AnomalyKind::Leet => {
format!("{:?} decodes to the word {:?}", self.token, self.detail)
}
AnomalyKind::Segmentation => {
format!("{:?} splits the word {:?}", self.token, self.detail)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AnomalyReport {
pub anomalous: bool,
pub kinds: Vec<AnomalyKind>,
pub findings: Vec<Finding>,
pub reason: Option<String>,
}
fn leet_sub(c: char) -> Option<char> {
match c {
'0' => Some('o'),
'1' | '!' => Some('i'),
'2' => Some('z'),
'3' => Some('e'),
'4' | '@' => Some('a'),
'5' | '$' => Some('s'),
'6' | '9' => Some('g'),
'7' | '+' => Some('t'),
'8' => Some('b'),
'|' => Some('l'),
_ => None,
}
}
fn codepoint(c: char) -> String {
format!("U+{:04X}", c as u32)
}
fn base_ascii(s: &str) -> String {
s.chars()
.filter(char::is_ascii_alphabetic)
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn leet_demangle(s: &str) -> Option<String> {
let mut out = String::new();
for c in s.chars() {
if c.is_alphabetic() {
out.extend(c.to_lowercase());
} else if let Some(m) = leet_sub(c) {
out.push(m);
} else if c == '\'' || c == '\u{2019}' {
} else {
return None;
}
}
Some(out)
}
fn is_majority_latin(tok: &str) -> bool {
let mut letters = 0usize;
let mut ascii = 0usize;
for c in tok.chars() {
if c.is_alphabetic() {
letters += 1;
if c.is_ascii() {
ascii += 1;
}
}
}
letters != 0 && ascii * 2 >= letters
}
fn has_no_letters(tok: &str) -> bool {
!tok.chars().any(char::is_alphabetic)
}
fn is_ordinal_or_time(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
for suf in ["st", "nd", "rd", "th", "am", "pm"] {
if let Some(num) = lower.strip_suffix(suf) {
if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
}
false
}
fn is_word_plus_trailing(s: &str) -> bool {
let mut chars = s.chars().peekable();
let mut letters = 0usize;
while let Some(&c) = chars.peek() {
if c.is_ascii_alphabetic() {
chars.next();
letters += 1;
} else {
break;
}
}
if letters == 0 {
return false;
}
let mut tail = 0usize;
for c in chars {
if c.is_ascii_digit() || matches!(c, '@' | '$' | '|') {
tail += 1;
} else {
return false;
}
}
tail > 0
}
fn nearest(d: &str, lexicon: &HashSet<String>) -> Option<String> {
let chars: Vec<char> = d.chars().collect();
let n = chars.len();
for i in 0..n {
let mut s = String::with_capacity(n.saturating_sub(1));
s.extend(chars[..i].iter().copied());
s.extend(chars[i + 1..].iter().copied());
if lexicon.contains(s.as_str()) {
return Some(s);
}
}
for i in 0..=n {
for c in b'a'..=b'z' {
let ch = c as char;
let mut ins = String::with_capacity(n + 1);
ins.extend(chars[..i].iter().copied());
ins.push(ch);
ins.extend(chars[i..].iter().copied());
if lexicon.contains(ins.as_str()) {
return Some(ins);
}
if i < n {
let mut sub = String::with_capacity(n);
sub.extend(chars[..i].iter().copied());
sub.push(ch);
sub.extend(chars[i + 1..].iter().copied());
if lexicon.contains(sub.as_str()) {
return Some(sub);
}
}
}
}
None
}
fn seg_word(core: &str, lexicon: &HashSet<String>) -> Option<String> {
let mut seps = 0usize;
let mut prev_sep = false;
for c in core.chars() {
let is_sep = matches!(c, '.' | '_' | '-');
if is_sep && !prev_sep {
seps += 1;
}
prev_sep = is_sep;
}
let letters: Vec<char> = core.chars().filter(|c| c.is_alphabetic()).collect();
if seps < 2 || 5 * seps < 3 * letters.len().saturating_sub(1) {
return None;
}
for part in core.split(['.', '_', '-']) {
if part.chars().count() > 1 && part.chars().any(char::is_alphabetic) {
return None;
}
}
let word: String = letters.iter().flat_map(|c| c.to_lowercase()).collect();
if word.chars().count() >= 4 && lexicon.contains(word.as_str()) {
Some(word)
} else {
None
}
}
fn classify(tok: &str, start: usize, lexicon: &HashSet<String>) -> Option<Finding> {
let end = start + tok.len();
let mk = |kind: AnomalyKind, detail: String| Finding {
kind,
token: tok.to_string(),
start,
end,
detail,
};
let core = tok.trim_matches(|c: char| WRAP.contains(&c));
if !tok.is_ascii() {
let chars: Vec<char> = tok.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if !INVISIBLE.contains(&c) {
continue;
}
let joiner = c == '\u{200C}' || c == '\u{200D}';
let letter = |slice: &[char]| {
if joiner {
slice.iter().any(char::is_ascii_alphabetic)
} else {
slice.iter().copied().any(char::is_alphabetic)
}
};
let before = letter(&chars[..i]);
let after = letter(&chars[i + 1..]);
let fire = if joiner {
before && after
} else {
before || after
};
if fire {
return Some(mk(AnomalyKind::Invisible, codepoint(c)));
}
}
if let Some(&c) = chars.iter().find(|c| BIDI_OVERRIDE.contains(c)) {
return Some(mk(AnomalyKind::Bidi, codepoint(c)));
}
if is_majority_latin(tok) || has_no_letters(tok) {
if let Some(&c) = chars.iter().find(|c| BIDI_ISOLATES.contains(c)) {
return Some(mk(AnomalyKind::Bidi, codepoint(c)));
}
}
if is_zalgo(tok, ZALGO_THRESHOLD) {
return Some(mk(
AnomalyKind::Zalgo,
"stacked combining marks".to_string(),
));
}
let core_lower = core.to_lowercase();
if core.chars().count() >= 2 && !UNITS.contains(&core_lower.as_str()) {
let scripts = detect_scripts(core);
if crate::scripts::has_bidi_conflict(core) {
return Some(mk(AnomalyKind::BidiMixed, scripts.join(" and ")));
}
let has_latin = scripts.contains(&"Latin");
let has_other = scripts
.iter()
.any(|s| *s != "Latin" && !CJK_SCRIPTS.contains(s));
if has_latin && has_other {
return Some(mk(AnomalyKind::MixedScript, scripts.join(" and ")));
}
}
}
if core.chars().count() < 2 {
return None;
}
let has_sym = core
.chars()
.any(|c| c.is_ascii_digit() || matches!(c, '@' | '$' | '|' | '!' | '+'));
if has_sym && core.chars().count() <= MAX_LEET_LEN {
if let Some(d) = leet_demangle(core) {
if !is_ordinal_or_time(core) {
let base = base_ascii(core);
let literal = base.chars().count() >= 4
&& lexicon.contains(base.as_str())
&& is_word_plus_trailing(core);
if base.chars().count() >= 2 && !literal && d.chars().count() >= 3 && d != base {
if lexicon.contains(d.as_str()) {
return Some(mk(AnomalyKind::Leet, d));
}
if d.chars().count() >= 6 {
if let Some(near) = nearest(&d, lexicon) {
return Some(mk(AnomalyKind::Leet, near));
}
}
}
}
}
}
if core.chars().any(|c| matches!(c, '.' | '_' | '-')) {
if let Some(word) = seg_word(core, lexicon) {
return Some(mk(AnomalyKind::Segmentation, word));
}
}
None
}
fn split_tokens(text: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (i, c) in text.char_indices() {
if c.is_whitespace() {
if let Some(s) = start.take() {
out.push((s, &text[s..i]));
}
} else if start.is_none() {
start = Some(i);
}
}
if let Some(s) = start {
out.push((s, &text[s..]));
}
out
}
#[must_use]
pub fn lexicon<I, S>(words: I) -> HashSet<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
words
.into_iter()
.map(|s| s.as_ref().to_lowercase())
.collect()
}
#[must_use]
pub fn has_anomalies(text: &str, lexicon: &HashSet<String>) -> bool {
split_tokens(text)
.into_iter()
.any(|(start, tok)| classify(tok, start, lexicon).is_some())
}
#[must_use]
pub fn inspect_anomalies(text: &str, lexicon: &HashSet<String>) -> AnomalyReport {
let tokens = split_tokens(text);
#[cfg(feature = "log")]
let token_count = tokens.len();
let mut findings = Vec::new();
for (start, tok) in tokens {
if let Some(f) = classify(tok, start, lexicon) {
findings.push(f);
}
}
let mut kinds: Vec<AnomalyKind> = Vec::new();
for f in &findings {
if !kinds.contains(&f.kind) {
kinds.push(f.kind);
}
}
let reason = findings.first().map(Finding::reason);
let anomalous = !findings.is_empty();
tl_debug!(
"inspect_anomalies: in_bytes={} tokens={} findings={} anomalous={}",
text.len(),
token_count,
findings.len(),
anomalous,
);
AnomalyReport {
anomalous,
kinds,
findings,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lex(words: &[&str]) -> HashSet<String> {
words.iter().map(|w| (*w).to_string()).collect()
}
#[test]
fn lexicon_lowercases_so_title_cased_wordlists_match() {
let title = lexicon(["Free".to_string(), "Viagra".to_string()]);
assert!(title.contains("free") && title.contains("viagra"));
assert!(has_anomalies("get fr33 now", &title)); assert!(has_anomalies("v.i.a.g.r.a", &title)); assert!(!has_anomalies("get fr33 now", &lex(&["Free"])));
}
#[test]
fn flags_homoglyph_leet_and_clears_clean() {
let l = lex(&["free", "viagra"]);
assert!(has_anomalies("get fr33 now", &l));
assert!(has_anomalies("payp\u{0430}l", &l)); assert!(!has_anomalies("the win32 api and mp3 file", &l));
assert!(!has_anomalies("perfectly clean sentence", &l));
}
#[test]
fn reports_reason_and_span() {
let l = lex(&["free"]);
let r = inspect_anomalies("get fr33", &l);
assert!(r.anomalous);
assert_eq!(r.kinds, vec![AnomalyKind::Leet]);
assert_eq!(r.findings[0].detail, "free");
}
#[test]
fn invisible_fires_inside_a_latin_word() {
let l = lex(&[]);
assert!(has_anomalies("pay\u{200B}pal", &l)); assert!(has_anomalies("he\u{200C}llo", &l)); assert!(has_anomalies("\u{00E9}\u{200B}\u{00E0}", &l)); }
#[test]
fn invisible_spares_emoji_and_non_latin_joiners() {
let l = lex(&[]);
assert!(!has_anomalies(
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
&l
));
assert!(!has_anomalies(
"\u{0643}\u{062A}\u{200D}\u{0627}\u{0628}",
&l
));
assert!(!has_anomalies("encyclo\u{00AD}pedia", &l));
}
#[test]
fn invisible_fires_at_word_edges_for_never_legit_codepoints() {
let l = lex(&[]);
assert!(has_anomalies("paypal\u{200B}", &l)); assert!(has_anomalies("\u{FEFF}paypal", &l)); assert!(has_anomalies("paypal\u{2060}", &l)); assert!(!has_anomalies("paypal\u{200D}", &l)); assert!(!has_anomalies("\u{200C}paypal", &l)); }
#[test]
fn bidi_fires_on_override_and_trojan_isolate() {
let l = lex(&[]);
assert!(has_anomalies("user\u{202E}txt.exe", &l)); assert!(has_anomalies("ab\u{2066}cd", &l)); }
#[test]
fn bidi_fires_on_isolate_in_letterless_token() {
let l = lex(&[]);
assert!(has_anomalies("12\u{2066}34", &l));
}
#[test]
fn bidi_spares_marks_and_embeddings() {
let l = lex(&[]);
assert!(!has_anomalies("hello\u{200F}world", &l));
assert!(!has_anomalies(
"\u{202B}\u{0639}\u{0631}\u{0628}\u{064A}\u{202C}",
&l
));
}
#[test]
fn zalgo_fires_but_spares_normal_accents() {
let l = lex(&[]);
assert!(has_anomalies("z\u{0301}\u{0301}\u{0301}\u{0301}algo", &l));
assert!(!has_anomalies("café résumé naïve", &l));
}
#[test]
fn mixed_script_fires_on_latin_plus_cyrillic_or_greek() {
let l = lex(&[]);
assert!(has_anomalies("payp\u{0430}l", &l)); assert!(has_anomalies("Vi\u{03B1}gra", &l)); }
#[test]
fn mixed_script_fires_on_latin_plus_any_non_cjk_script() {
let l = lex(&[]);
assert!(has_anomalies("payp\u{0561}l", &l)); assert!(has_anomalies("Chero\u{13A0}kee", &l)); assert!(has_anomalies("Co\u{2C81}pt", &l)); }
#[test]
fn mixed_script_spares_cjk_units_and_single_scripts() {
let l = lex(&[]);
assert!(!has_anomalies("漢字api", &l)); assert!(!has_anomalies("カナkana", &l)); assert!(!has_anomalies("한글text", &l)); assert!(!has_anomalies("漢字 mixed with text", &l)); assert!(!has_anomalies("kΩ µF resistor", &l)); assert!(!has_anomalies("Москва Россия", &l)); }
#[test]
fn bidi_mixed_fires_on_ltr_plus_rtl_token() {
let l = lex(&[]);
let r = inspect_anomalies("varonis\u{05D5}", &l);
assert!(r.anomalous);
assert_eq!(r.kinds, vec![AnomalyKind::BidiMixed]);
}
#[test]
fn bidi_mixed_catches_non_latin_rtl_mix_missed_by_mixed_script() {
let l = lex(&[]);
let r = inspect_anomalies("\u{0430}\u{05D5}\u{05DD}", &l);
assert!(r.anomalous);
assert_eq!(r.kinds, vec![AnomalyKind::BidiMixed]);
}
#[test]
fn bidi_mixed_does_not_fire_on_same_direction_mix() {
let l = lex(&[]);
let r = inspect_anomalies("payp\u{0430}l", &l);
assert_eq!(r.kinds, vec![AnomalyKind::MixedScript]);
assert!(!has_anomalies("\u{05D0}\u{05EA}\u{05E8}", &l)); }
#[test]
fn leet_decodes_substitutions_to_words() {
let l = lex(&["free", "about", "the", "dont", "pass"]);
assert!(has_anomalies("get fr33 stuff", &l));
assert!(has_anomalies("talk ab0ut it", &l)); assert!(has_anomalies("th3 answer", &l)); assert!(has_anomalies("d0n't", &l)); assert!(has_anomalies("p@ss", &l)); }
#[test]
fn leet_decodes_extended_substitutions() {
let l = lex(&["friend", "table", "ghost", "abuse"]);
assert!(has_anomalies("fr!end", &l)); assert!(has_anomalies("+able", &l)); assert!(has_anomalies("6host", &l)); assert!(has_anomalies("a8use", &l)); assert!(!has_anomalies("fr%end", &l)); assert!(!has_anomalies("ta#le", &l)); }
#[test]
fn leet_spares_literal_numbers() {
let l = lex(&["power", "covid"]);
assert!(!has_anomalies("the win32 api and mp3 file", &l));
assert!(!has_anomalies("Power5 chip", &l)); assert!(!has_anomalies("covid19 update", &l));
assert!(!has_anomalies("on the 21st at 3pm", &l)); }
#[test]
fn leet_skips_overlong_tokens() {
let l = lex(&["free"]);
let long = "3".repeat(100); assert!(!has_anomalies(&long, &l));
assert!(has_anomalies("fr33", &l));
}
#[test]
fn segmentation_fires_on_dense_single_letter_splits() {
let l = lex(&["viagra"]);
assert!(has_anomalies("buy v.i.a.g.r.a now", &l));
assert!(has_anomalies("v_i_a_g_r_a", &l));
}
#[test]
fn segmentation_collapses_separator_padding() {
let l = lex(&["viagra"]);
assert!(has_anomalies("v-.-i-.-a-.-g-.-r-.-a", &l)); assert!(!has_anomalies("via---gra", &l));
}
#[test]
fn clean_text_reports_nothing() {
let l = lex(&["free", "viagra"]);
let r = inspect_anomalies("a perfectly ordinary sentence", &l);
assert!(!r.anomalous);
assert!(r.kinds.is_empty());
assert!(r.findings.is_empty());
assert!(r.reason.is_none());
}
#[test]
fn inspect_records_span_kind_and_reason() {
let l = lex(&["paypal"]);
let r = inspect_anomalies("log in to payp\u{0430}l today", &l);
assert_eq!(r.kinds, vec![AnomalyKind::MixedScript]);
let f = &r.findings[0];
assert_eq!(f.kind, AnomalyKind::MixedScript);
assert_eq!(&f.token, "payp\u{0430}l");
assert_eq!(&"log in to payp\u{0430}l today"[f.start..f.end], f.token);
assert!(r.reason.unwrap().contains("Latin"));
}
#[test]
fn has_anomalies_matches_inspect() {
let l = lex(&["free", "viagra", "paypal"]);
for s in [
"get fr33",
"payp\u{0430}l",
"v.i.a.g.r.a",
"perfectly clean text",
"user\u{202E}txt",
] {
assert_eq!(has_anomalies(s, &l), inspect_anomalies(s, &l).anomalous);
}
}
}