use std::collections::HashSet;
use unicode_normalization::UnicodeNormalization;
use crate::scripts::detect_scripts;
use crate::zalgo::is_zalgo;
const ZALGO_THRESHOLD: usize = 3;
const MAX_LEET_LEN: usize = 64;
#[inline]
fn is_invisible_in_word(c: char) -> bool {
crate::invisibles::is_zero_width(c)
|| crate::invisibles::is_invisible_filler(c)
|| crate::invisibles::is_default_ignorable_format(c)
}
const SOFT_HYPHEN: char = '\u{00AD}';
const CGJ: char = '\u{034F}';
const RUN_THRESHOLD_TAG: usize = 1;
const RUN_THRESHOLD_VARIATION_SELECTOR: usize = 2;
const RUN_THRESHOLD_ZERO_WIDTH: usize = 8;
const RUN_THRESHOLD_PRIVATE_USE: usize = 4;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Carrier {
Tag,
VariationSelector,
ZeroWidth,
PrivateUse,
}
impl Carrier {
fn of(c: char) -> Option<Self> {
if crate::invisibles::is_tag(c) {
Some(Self::Tag)
} else if crate::invisibles::is_variation_selector(c) {
Some(Self::VariationSelector)
} else if is_invisible_in_word(c) || c == SOFT_HYPHEN || c == CGJ {
Some(Self::ZeroWidth)
} else if crate::invisibles::is_pua(c) {
Some(Self::PrivateUse)
} else {
None
}
}
fn threshold(self) -> usize {
match self {
Self::Tag => RUN_THRESHOLD_TAG,
Self::VariationSelector => RUN_THRESHOLD_VARIATION_SELECTOR,
Self::ZeroWidth => RUN_THRESHOLD_ZERO_WIDTH,
Self::PrivateUse => RUN_THRESHOLD_PRIVATE_USE,
}
}
}
const BIDI_OVERRIDE: &[char] = &['\u{202D}', '\u{202E}'];
const BIDI_ISOLATES: &[char] = &['\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
const BIDI_EMBEDDINGS: &[char] = &['\u{202A}', '\u{202B}', '\u{202C}'];
const BIDI_RTL_MARKS: &[char] = &['\u{200F}', '\u{061C}'];
const ENCLOSING_MARKS: &[char] = &[
'\u{0488}', '\u{0489}', '\u{1ABE}', '\u{20DD}', '\u{20DE}', '\u{20DF}', '\u{20E0}', '\u{20E2}',
'\u{20E3}', '\u{20E4}', '\u{A670}', '\u{A671}', '\u{A672}',
];
const MIN_WHOLLY_CONFUSABLE: usize = 4;
fn is_wholly_confusable_word(part: &str) -> bool {
let core: Vec<char> = part.chars().filter(|c| !c.is_whitespace()).collect();
if core.len() < MIN_WHOLLY_CONFUSABLE {
return false;
}
let folds_to_letter = |c: char| {
crate::tables::lookup_confusable(c, "latin")
.is_some_and(|t| t.len() == 1 && t.chars().all(|f| f.is_ascii_alphabetic()))
};
if !core.iter().copied().all(folds_to_letter) {
return false;
}
if core.iter().copied().all(ordinary_as_a_whole_token) {
return false;
}
let scripts = detect_scripts(part);
scripts.is_empty() || scripts == ["Latin"]
}
fn ordinary_as_a_whole_token(ch: char) -> bool {
matches!(ch,
'\u{FF00}'..='\u{FFEF}' | '\u{3300}'..='\u{33FF}' | '\u{2100}'..='\u{214F}' )
}
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,
Control,
Deletion,
Smuggled,
CompatFold,
Confusable,
EnclosingMark,
MixedNumbers,
DuplicateMark,
}
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",
AnomalyKind::Control => "control",
AnomalyKind::Deletion => "deletion",
AnomalyKind::Smuggled => "smuggled",
AnomalyKind::CompatFold => "compat_fold",
AnomalyKind::Confusable => "confusable",
AnomalyKind::EnclosingMark => "enclosing_mark",
AnomalyKind::MixedNumbers => "mixed_numbers",
AnomalyKind::DuplicateMark => "duplicate_mark",
}
}
}
#[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)
}
AnomalyKind::Control => match erased_by_deletion(&self.token) {
Some(erased) => format!(
"{:?} contains the control character {}, which erases the preceding {:?}",
self.token, self.detail, erased
),
None => format!("{:?} contains the control character {}", self.token, self.detail),
},
AnomalyKind::Smuggled => format!(
"a hidden {} run decodes to {:?}",
self.detail, self.token
),
AnomalyKind::Deletion => format!(
"{:?} is overwritten by what follows the carriage return, so it renders as \
text these code points do not spell",
self.token
),
AnomalyKind::CompatFold => format!(
"{:?} mixes a compatibility form with ASCII and folds to {}",
self.token, self.detail
),
AnomalyKind::Confusable => {
format!("{:?} contains a confusable: {}", self.token, self.detail)
}
AnomalyKind::EnclosingMark => format!(
"{:?} carries enclosing marks that hide the base text: {}",
self.token, self.detail
),
AnomalyKind::MixedNumbers => format!(
"{:?} mixes digits from {} (UTS #39 Mixed Numbers)",
self.token, self.detail
),
AnomalyKind::DuplicateMark => format!(
"{:?} repeats the same combining mark ({}), which renders as one",
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>,
}
const NEAR_MISS_MIN_LEN: usize = 5;
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 erased_by_deletion(token: &str) -> Option<char> {
let mut prev = None;
for c in token.chars() {
if matches!(c, '\u{8}' | '\u{7F}') {
if let Some(p) = prev {
return Some(p);
}
}
prev = Some(c);
}
None
}
fn is_line_break(c: char) -> bool {
matches!(
c,
'\n' | '\u{B}' | '\u{C}' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}'
)
}
fn decoded_payloads(text: &str) -> impl Iterator<Item = crate::smuggled::Payload> {
crate::smuggled::decode_carriers(text)
.into_iter()
.filter(|p| p.text.is_some())
}
fn overwriting_cr(text: &str) -> Option<(usize, &str)> {
let mut line_start = 0;
for (i, c) in text.char_indices() {
if c == '\r' {
let overwrites = text[i + 1..].chars().next().is_some_and(|n| n != '\n');
if overwrites && i > line_start {
return Some((line_start, &text[line_start..i]));
}
}
if is_line_break(c) {
line_start = i + c.len_utf8();
}
}
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 whole_token_compat_is_ordinary(ch: char) -> bool {
matches!(ch,
'\u{FF00}'..='\u{FFEF}' | '\u{3300}'..='\u{33FF}' | '\u{2100}'..='\u{214F}' | '\u{0250}'..='\u{02AF}' | '\u{1D00}'..='\u{1D7F}' | '\u{1D80}'..='\u{1DBF}' | '\u{1F100}'..='\u{1F1FF}' )
}
fn duplicate_stacking_mark(tok: &str) -> Option<char> {
use unicode_normalization::char::{canonical_combining_class, is_combining_mark};
use unicode_normalization::UnicodeNormalization;
let mut previous: Option<char> = None;
for ch in tok.nfd() {
if is_combining_mark(ch) && canonical_combining_class(ch) != 0 {
if previous == Some(ch) {
return Some(ch);
}
previous = Some(ch);
} else {
previous = None;
}
}
None
}
fn folded_confusable(tok: &str) -> Option<(char, &'static str)> {
fn ascii_target(c: char) -> Option<&'static str> {
crate::tables::lookup_confusable(c, "latin").filter(|t| t.is_ascii())
}
tok.chars().find_map(|c| {
if c.is_ascii() {
return None;
}
if let Some(target) = ascii_target(c) {
return Some((c, target));
}
c.nfkc()
.find(|f| !f.is_ascii() && ascii_target(*f).is_some())
.and_then(|f| ascii_target(f).map(|target| (c, target)))
})
}
fn enclosing_marks(tok: &str) -> Vec<char> {
const KEYCAP: char = '\u{20E3}';
const VS16: char = '\u{FE0F}';
let chars: Vec<char> = tok.chars().collect();
let mut out = Vec::new();
for (i, &c) in chars.iter().enumerate() {
if !ENCLOSING_MARKS.contains(&c) {
continue;
}
if c == KEYCAP && i > 0 && chars[i - 1] == VS16 {
continue;
}
let base_is_cyrillic = chars[..i]
.iter()
.rev()
.find(|b| !unicode_normalization::char::is_combining_mark(**b))
.is_some_and(|b| crate::scripts::detect_char_script(*b) == "Cyrillic");
if base_is_cyrillic {
continue;
}
out.push(c);
}
out
}
fn leet_edge_core(tok: &str) -> &str {
tok.trim_matches(|c: char| WRAP.contains(&c) && leet_sub(c).is_none() && c != '(' && c != ')')
}
#[inline]
fn is_segment_separator(c: char) -> bool {
crate::tables::is_word_joiner(c) || (c.is_whitespace() && !is_token_boundary(c))
}
fn space_fragmented_word(core: &str, lexicon: &HashSet<String>) -> Option<String> {
let chars: Vec<char> = core.chars().collect();
let mut word_internal = false;
let mut joined = String::with_capacity(core.len());
for (i, &c) in chars.iter().enumerate() {
if c.is_whitespace() && !is_token_boundary(c) {
let before = chars[..i]
.iter()
.next_back()
.is_some_and(|p| p.is_alphabetic());
let after = chars[i + 1..]
.iter()
.next()
.is_some_and(|n| n.is_alphabetic());
if before && after {
word_internal = true;
}
continue;
}
joined.extend(c.to_lowercase());
}
if !word_internal || joined.chars().count() < 4 {
return None;
}
lexicon.contains(joined.as_str()).then_some(joined)
}
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 = is_segment_separator(c);
if is_sep && !prev_sep {
seps += 1;
}
prev_sep = is_sep;
}
let letters: Vec<char> = core
.chars()
.filter(|c| !is_segment_separator(*c))
.filter_map(|c| {
if c.is_alphabetic() {
Some(c)
} else {
leet_sub(c)
}
})
.collect();
if seps < 2 || 5 * seps < 3 * letters.len().saturating_sub(1) {
return None;
}
for part in core.split(is_segment_separator) {
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 carrier_run(chars: &[char]) -> Option<(char, usize)> {
let mut i = 0;
let mut best: Option<(char, usize)> = None;
while i < chars.len() {
if let Some(len) = crate::invisibles::subdivision_flag_len(&chars[i..]) {
i += len;
continue;
}
let Some(class) = Carrier::of(chars[i]) else {
i += 1;
continue;
};
let start = i;
while i < chars.len() && Carrier::of(chars[i]) == Some(class) {
i += 1;
}
let len = i - start;
let longer = match best {
None => true,
Some((_, best_len)) => len > best_len,
};
if len >= class.threshold() && longer {
best = Some((chars[start], len));
}
}
best
}
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 let Some(c) = tok
.chars()
.find(|&c| c.is_control() && !crate::whitespace::is_fold_whitespace(c))
{
return Some(mk(AnomalyKind::Control, codepoint(c)));
}
if !tok.is_ascii() {
let systems = crate::digits::system_count(tok);
if systems > 1 {
return Some(mk(
AnomalyKind::MixedNumbers,
format!("{systems} decimal numbering systems"),
));
}
let chars: Vec<char> = tok.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if !is_invisible_in_word(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, len)) = carrier_run(&chars) {
return Some(mk(
AnomalyKind::Invisible,
format!("{} \u{d7}{len}", 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) || BIDI_EMBEDDINGS.contains(c))
{
return Some(mk(AnomalyKind::Bidi, codepoint(c)));
}
if let Some(i) = chars.iter().position(|c| BIDI_RTL_MARKS.contains(c)) {
if chars.get(i + 1).is_some_and(char::is_ascii_digit) {
return Some(mk(AnomalyKind::Bidi, codepoint(chars[i])));
}
}
}
let enclosing = enclosing_marks(tok);
if enclosing.len() >= 2 {
return Some(mk(
AnomalyKind::EnclosingMark,
format!("{} \u{d7}{}", codepoint(enclosing[0]), enclosing.len()),
));
}
if is_zalgo(tok, ZALGO_THRESHOLD) {
return Some(mk(
AnomalyKind::Zalgo,
"stacked combining marks".to_string(),
));
}
if let Some(dup) = duplicate_stacking_mark(tok) {
return Some(mk(AnomalyKind::DuplicateMark, codepoint(dup)));
}
for part in word_parts(core) {
let part_lower = part.to_lowercase();
if part.chars().count() < 2 || UNITS.contains(&part_lower.as_str()) {
continue;
}
let scripts = detect_scripts(part);
if crate::scripts::has_bidi_letter_conflict(part) {
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 ")));
}
}
for part in word_parts(tok) {
let has_ascii_letter = part.chars().any(|c| c.is_ascii_alphabetic());
let compat: Vec<char> = part
.chars()
.filter(|c| !c.is_ascii() && c.nfkc().all(|f| f.is_ascii()))
.collect();
let spared =
!has_ascii_letter && compat.iter().copied().all(whole_token_compat_is_ordinary);
if !compat.is_empty() && !spared {
return Some(mk(AnomalyKind::CompatFold, tok.nfkc().collect::<String>()));
}
}
}
if core.chars().count() < 2 {
return None;
}
for source in [core, leet_edge_core(tok)] {
for core in leet_parts(source) {
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() >= NEAR_MISS_MIN_LEN {
if let Some(near) = nearest(&d, lexicon) {
return Some(mk(AnomalyKind::Leet, near));
}
}
}
}
}
}
}
}
if core.chars().any(is_segment_separator) {
if let Some(word) = seg_word(core, lexicon) {
return Some(mk(AnomalyKind::Segmentation, word));
}
if let Some(word) = space_fragmented_word(core, lexicon) {
return Some(mk(AnomalyKind::Segmentation, word));
}
}
if !tok.is_ascii() {
for part in word_parts(tok) {
if !part.chars().any(|c| c.is_ascii_alphabetic()) && !is_wholly_confusable_word(part) {
continue;
}
if UNITS.contains(&part.to_lowercase().as_str()) {
continue;
}
if let Some((source, target)) = folded_confusable(part) {
return Some(mk(
AnomalyKind::Confusable,
format!("{source} (U+{:04X}) folds to {target}", source as u32),
));
}
}
}
None
}
#[inline]
fn is_token_boundary(c: char) -> bool {
c.is_ascii_whitespace() || matches!(c, '\u{000B}' | '\u{0085}' | '\u{2028}' | '\u{2029}')
}
pub(crate) fn word_parts(s: &str) -> impl Iterator<Item = &str> {
s.split(|c: char| c.is_whitespace() || matches!(c, '-' | '_' | '/' | ':' | '@' | ','))
.filter(|p| !p.is_empty())
}
fn leet_parts(s: &str) -> impl Iterator<Item = &str> {
s.split(|c: char| c.is_whitespace())
.filter(|p| !p.is_empty())
}
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 is_token_boundary(c) {
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 {
overwriting_cr(text).is_some()
|| decoded_payloads(text).next().is_some()
|| 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 smuggled: Vec<Finding> = decoded_payloads(text)
.map(|p| Finding {
kind: AnomalyKind::Smuggled,
token: p.text.clone().unwrap_or_default(),
start: p.start,
end: p.end,
detail: p.scheme.as_str().to_owned(),
})
.collect();
for f in smuggled.into_iter().rev() {
findings.insert(0, f);
}
if let Some((start, overwritten)) = overwriting_cr(text) {
let at = findings.partition_point(|f: &Finding| f.start < start);
findings.insert(
at,
Finding {
kind: AnomalyKind::Deletion,
token: overwritten.to_owned(),
start,
end: start + overwritten.len(),
detail: codepoint('\r'),
},
);
}
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::*;
#[test]
fn wholly_confusable_word_holds_on_its_own_terms() {
assert!(!is_wholly_confusable_word(
"\u{1D18}\u{1D00}ss\u{1D21}\u{1D0F}\u{0280}\u{1D05}"
));
assert!(!is_wholly_confusable_word(
"\u{1D18}\u{1D00}\u{4E2D}\u{1D05}"
));
assert!(!is_wholly_confusable_word("\u{1D00}\u{1D05}\u{1D0D}"));
assert!(!is_wholly_confusable_word(
"\u{041C}\u{043E}\u{0441}\u{043A}\u{0432}\u{0430}"
));
assert!(is_wholly_confusable_word(
"\u{1D18}\u{1D00}\u{A731}\u{A731}\u{1D21}\u{1D0F}\u{0280}\u{1D05}"
));
}
#[test]
fn ascii_is_nfkc_stable_so_the_fast_path_is_safe() {
use unicode_normalization::UnicodeNormalization;
for cp in 0u32..0x80 {
let s = char::from_u32(cp).unwrap().to_string();
assert!(
s.nfkc().eq(s.chars()),
"ASCII U+{cp:04X} is not NFKC-stable"
);
}
}
#[test]
fn a_token_half_in_fullwidth_is_flagged() {
for (tok, folds_to) in [
("\u{FF1C}script\u{FF1E}", "<script>"),
("\u{FF41}dmin", "admin"),
("\u{FF45}xample.com", "example.com"),
] {
let r = inspect_anomalies(tok, &HashSet::new());
assert_eq!(r.kinds, vec![AnomalyKind::CompatFold], "{tok:?}");
assert_eq!(r.findings[0].detail, folds_to);
}
}
#[test]
fn ordinary_fullwidth_typography_is_not_flagged() {
for tok in [
"\u{FF2E}\u{FF28}\u{FF2B}", "\u{FF31}\u{FF06}\u{FF21}", "\u{FF11}\u{FF19}\u{FF19}\u{FF15}\u{5E74}", "\u{FF23}\u{FF24}\u{FF0D}\u{FF32}\u{FF2F}\u{FF2D}", ] {
assert!(
inspect_anomalies(tok, &HashSet::new()).kinds.is_empty(),
"{tok:?} was flagged"
);
}
}
#[test]
fn a_token_wholly_in_fullwidth_is_deliberately_not_flagged() {
for tok in [
"\u{FF50}\u{FF41}\u{FF59}\u{FF50}\u{FF41}\u{FF4C}", "\u{FF11}\u{FF12}\u{FF13}", ] {
assert!(
inspect_anomalies(tok, &HashSet::new()).kinds.is_empty(),
"{tok:?}"
);
}
}
#[test]
fn unit_symbols_fold_to_greek_and_are_not_a_disguise() {
for tok in [
"k\u{2126}",
"\u{B5}F",
"\u{B5}s",
"100\u{2126}",
"10\u{338F}",
"5\u{339E}",
"3\u{33A1}",
"100\u{339C}",
] {
assert!(
inspect_anomalies(tok, &HashSet::new()).kinds.is_empty(),
"{tok:?} was flagged"
);
}
}
#[test]
fn other_compatibility_forms_count_too() {
let r = inspect_anomalies("\u{FB01}le", &HashSet::new()); assert_eq!(r.kinds, vec![AnomalyKind::CompatFold]);
assert_eq!(r.findings[0].detail, "file");
}
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
));
assert!(has_anomalies("\u{202B}if(isAdmin){grant();}\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);
}
}
}