use super::{Location, ScanResult, Severity, Threat, ThreatType};
use std::sync::atomic::{AtomicU64, Ordering};
use unicode_security::MixedScript;
pub struct UnicodeScanner {
threats_detected: AtomicU64,
total_scans: AtomicU64,
#[allow(dead_code)]
#[cfg(feature = "enhanced")]
enhanced_mode: bool,
allow_text_control_chars: bool,
}
impl Default for UnicodeScanner {
fn default() -> Self {
Self::new()
}
}
impl UnicodeScanner {
pub const fn new() -> Self {
Self {
threats_detected: AtomicU64::new(0),
total_scans: AtomicU64::new(0),
#[cfg(feature = "enhanced")]
enhanced_mode: false,
allow_text_control_chars: false,
}
}
pub const fn with_config(allow_text_control_chars: bool) -> Self {
Self {
threats_detected: AtomicU64::new(0),
total_scans: AtomicU64::new(0),
#[cfg(feature = "enhanced")]
enhanced_mode: false,
allow_text_control_chars,
}
}
#[allow(dead_code)]
#[cfg(feature = "enhanced")]
pub(crate) fn enable_enhancement(&mut self) {
self.enhanced_mode = true;
}
pub fn scan_text(&self, text: &str) -> ScanResult {
self.total_scans.fetch_add(1, Ordering::Relaxed);
let mut threats = Vec::new();
#[cfg(feature = "enhanced")]
if self.enhanced_mode {
tracing::trace!("Enhanced unicode scanning active for {} chars", text.len());
}
for (pos, ch) in text.char_indices() {
if is_invisible_char(ch) {
threats.push(Threat {
threat_type: ThreatType::UnicodeInvisible,
severity: Severity::High,
location: Location::Text {
offset: pos,
length: ch.len_utf8(),
},
description: format!("Invisible character U+{:04X} detected", ch as u32),
remediation: Some("Remove or replace invisible characters".to_string()),
});
}
if is_bidi_control(ch) {
threats.push(Threat {
threat_type: ThreatType::UnicodeBiDi,
severity: Severity::Critical,
location: Location::Text {
offset: pos,
length: ch.len_utf8(),
},
description: format!(
"BiDi control character U+{:04X} can reverse text display",
ch as u32
),
remediation: Some(
"Remove BiDi control characters or validate text direction".to_string(),
),
});
}
if self.is_dangerous_control(ch) {
threats.push(Threat {
threat_type: ThreatType::UnicodeControl,
severity: Severity::Medium,
location: Location::Text {
offset: pos,
length: ch.len_utf8(),
},
description: format!("Dangerous control character U+{:04X}", ch as u32),
remediation: Some("Filter out control characters".to_string()),
});
}
}
if !text.is_single_script() {
threats.push(Threat {
threat_type: ThreatType::UnicodeHomograph,
severity: Severity::High,
location: Location::Text {
offset: 0,
length: text.len(),
},
description: "Mixed scripts detected - potential homograph attack".to_string(),
remediation: Some(
"Restrict to single script or validate mixed script usage".to_string(),
),
});
}
let has_confusables = text.chars().any(|ch| {
matches!(ch, '\u{0430}'..='\u{044F}' | '\u{0410}'..='\u{042F}' | '\u{1D00}'..='\u{1D7F}' | '\u{2100}'..='\u{214F}') });
if has_confusables {
threats.push(Threat {
threat_type: ThreatType::UnicodeHomograph,
severity: Severity::Medium,
location: Location::Text {
offset: 0,
length: text.len(),
},
description: "Text contains potentially confusable characters".to_string(),
remediation: Some(
"Consider restricting to ASCII or validated Unicode subsets".to_string(),
),
});
}
if !threats.is_empty() {
self.threats_detected
.fetch_add(threats.len() as u64, Ordering::Relaxed);
}
Ok(threats)
}
pub fn threats_detected(&self) -> u64 {
self.threats_detected.load(Ordering::Relaxed)
}
pub fn total_scans(&self) -> u64 {
self.total_scans.load(Ordering::Relaxed)
}
fn is_dangerous_control(&self, ch: char) -> bool {
if self.allow_text_control_chars {
match ch {
'\n' | '\r' | '\t' => false, _ => is_dangerous_control(ch),
}
} else {
is_dangerous_control(ch)
}
}
}
const fn is_invisible_char(ch: char) -> bool {
matches!(
ch,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}' | '\u{180E}' | '\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{115F}' | '\u{1160}' | '\u{17B4}' | '\u{17B5}' | '\u{3164}' )
}
const fn is_bidi_control(ch: char) -> bool {
matches!(
ch,
'\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
}
const fn is_dangerous_control(ch: char) -> bool {
match ch {
'\0' => true, '\u{0001}'..='\u{001F}' => true, '\u{007F}' => true, '\u{0080}'..='\u{009F}' => true, _ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invisible_char_detection() {
let scanner = UnicodeScanner::new();
let threats = scanner.scan_text("Hello\u{200B}World").unwrap();
assert_eq!(threats.len(), 1);
assert_eq!(threats[0].threat_type, ThreatType::UnicodeInvisible);
let threats = scanner.scan_text("Hello World").unwrap();
assert_eq!(threats.len(), 0);
}
#[test]
fn test_bidi_detection() {
let scanner = UnicodeScanner::new();
let threats = scanner.scan_text("Hello\u{202E}World").unwrap();
assert!(threats
.iter()
.any(|t| t.threat_type == ThreatType::UnicodeBiDi));
assert!(threats.iter().any(|t| t.severity == Severity::Critical));
}
#[test]
fn test_mixed_script_detection() {
let scanner = UnicodeScanner::new();
let threats = scanner.scan_text("Hellо").unwrap(); assert!(threats
.iter()
.any(|t| t.threat_type == ThreatType::UnicodeHomograph));
}
#[test]
fn test_null_byte_detection() {
let scanner = UnicodeScanner::new();
let threats = scanner.scan_text("Hello\0World").unwrap();
assert!(threats
.iter()
.any(|t| t.threat_type == ThreatType::UnicodeControl));
}
}
#[cfg(test)]
mod text_control_tests {
use super::super::*;
#[test]
fn test_allow_text_control_chars() {
let scanner = UnicodeScanner::with_config(true);
let threats = scanner.scan_text("Hello\nWorld").unwrap();
assert_eq!(
threats.len(),
0,
"Newline should not be flagged when allowed"
);
let threats = scanner.scan_text("Hello\tWorld").unwrap();
assert_eq!(threats.len(), 0, "Tab should not be flagged when allowed");
let threats = scanner.scan_text("Hello\rWorld").unwrap();
assert_eq!(
threats.len(),
0,
"Carriage return should not be flagged when allowed"
);
let threats = scanner.scan_text("Hello\0World").unwrap();
assert_eq!(threats.len(), 1, "Null byte should still be flagged");
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
let threats = scanner.scan_text("Hello\u{0001}World").unwrap();
assert_eq!(
threats.len(),
1,
"Other control chars should still be flagged"
);
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
}
#[test]
fn test_strict_control_char_detection() {
let scanner = UnicodeScanner::new();
let threats = scanner.scan_text("Hello\nWorld").unwrap();
assert_eq!(threats.len(), 1, "Newline should be flagged in strict mode");
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
let threats = scanner.scan_text("Hello\tWorld").unwrap();
assert_eq!(threats.len(), 1, "Tab should be flagged in strict mode");
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
let threats = scanner.scan_text("Hello\rWorld").unwrap();
assert_eq!(
threats.len(),
1,
"Carriage return should be flagged in strict mode"
);
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
}
#[test]
fn test_mixed_control_chars() {
let scanner = UnicodeScanner::with_config(true);
let text = "Line1\nLine2\0Line3\tColumn";
let threats = scanner.scan_text(text).unwrap();
assert_eq!(
threats.len(),
1,
"Only dangerous control chars should be flagged"
);
assert_eq!(threats[0].threat_type, ThreatType::UnicodeControl);
assert!(
threats[0].description.contains("U+0000"),
"Should identify null byte"
);
}
}