use llm_shield_core::{
async_trait, Entity, Error, Result, RiskFactor, ScanResult, Scanner, ScannerType, Severity,
Vault,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvisibleTextConfig {
pub threshold: f32,
pub remove: bool,
pub detect_zero_width: bool,
pub detect_control: bool,
pub detect_direction_marks: bool,
pub detect_non_printable: bool,
}
impl Default for InvisibleTextConfig {
fn default() -> Self {
Self {
threshold: 0.1, remove: true,
detect_zero_width: true,
detect_control: true,
detect_direction_marks: true,
detect_non_printable: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InvisibleCharType {
ZeroWidth,
Control,
DirectionMark,
NonPrintable,
}
impl InvisibleCharType {
fn as_str(&self) -> &str {
match self {
Self::ZeroWidth => "zero_width",
Self::Control => "control_character",
Self::DirectionMark => "direction_mark",
Self::NonPrintable => "non_printable",
}
}
fn confidence(&self) -> f32 {
match self {
Self::ZeroWidth => 1.0, Self::Control => 0.9, Self::DirectionMark => 0.7, Self::NonPrintable => 0.85, }
}
}
const ZERO_WIDTH_CHARS: &[char] = &[
'\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}', '\u{2060}', '\u{180E}', ];
const DIRECTION_MARKS: &[char] = &[
'\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', ];
pub struct InvisibleText {
config: InvisibleTextConfig,
}
impl InvisibleText {
pub fn new(config: InvisibleTextConfig) -> Result<Self> {
if !(0.0..=1.0).contains(&config.threshold) {
return Err(Error::config("Threshold must be between 0.0 and 1.0"));
}
Ok(Self { config })
}
pub fn default_config() -> Result<Self> {
Self::new(InvisibleTextConfig::default())
}
fn detect_invisible_chars(&self, text: &str) -> Vec<InvisibleMatch> {
let mut matches = Vec::new();
for (idx, ch) in text.char_indices() {
if self.config.detect_zero_width && ZERO_WIDTH_CHARS.contains(&ch) {
matches.push(InvisibleMatch {
position: idx,
character: ch,
char_type: InvisibleCharType::ZeroWidth,
name: self.get_char_name(ch),
});
continue;
}
if self.config.detect_direction_marks && DIRECTION_MARKS.contains(&ch) {
matches.push(InvisibleMatch {
position: idx,
character: ch,
char_type: InvisibleCharType::DirectionMark,
name: self.get_char_name(ch),
});
continue;
}
if self.config.detect_control && ch.is_control() && ch != '\n' && ch != '\r' && ch != '\t' {
matches.push(InvisibleMatch {
position: idx,
character: ch,
char_type: InvisibleCharType::Control,
name: format!("U+{:04X}", ch as u32),
});
continue;
}
if self.config.detect_non_printable
&& !ch.is_whitespace()
&& !ch.is_alphanumeric()
&& !ch.is_ascii_punctuation()
&& ch.is_control()
{
matches.push(InvisibleMatch {
position: idx,
character: ch,
char_type: InvisibleCharType::NonPrintable,
name: format!("U+{:04X}", ch as u32),
});
}
}
matches
}
fn get_char_name(&self, ch: char) -> String {
match ch {
'\u{200B}' => "Zero Width Space (ZWSP)".to_string(),
'\u{200C}' => "Zero Width Non-Joiner (ZWNJ)".to_string(),
'\u{200D}' => "Zero Width Joiner (ZWJ)".to_string(),
'\u{FEFF}' => "Zero Width No-Break Space (BOM)".to_string(),
'\u{2060}' => "Word Joiner".to_string(),
'\u{180E}' => "Mongolian Vowel Separator".to_string(),
'\u{202A}' => "Left-to-Right Embedding".to_string(),
'\u{202B}' => "Right-to-Left Embedding".to_string(),
'\u{202C}' => "Pop Directional Formatting".to_string(),
'\u{202D}' => "Left-to-Right Override".to_string(),
'\u{202E}' => "Right-to-Left Override".to_string(),
'\u{2066}' => "Left-to-Right Isolate".to_string(),
'\u{2067}' => "Right-to-Left Isolate".to_string(),
'\u{2068}' => "First Strong Isolate".to_string(),
'\u{2069}' => "Pop Directional Isolate".to_string(),
_ => format!("U+{:04X}", ch as u32),
}
}
fn calculate_risk_score(&self, matches: &[InvisibleMatch], text_len: usize) -> f32 {
if matches.is_empty() {
return 0.0;
}
let invisible_density = matches.len() as f32 / text_len.max(1) as f32;
let avg_confidence = matches
.iter()
.map(|m| m.char_type.confidence())
.sum::<f32>() / matches.len() as f32;
(invisible_density * 0.7 + avg_confidence * 0.3).min(1.0)
}
fn sanitize_text(&self, text: &str, matches: &[InvisibleMatch]) -> String {
if !self.config.remove || matches.is_empty() {
return text.to_string();
}
let mut result = String::with_capacity(text.len());
let invisible_positions: HashMap<usize, char> = matches
.iter()
.map(|m| (m.position, m.character))
.collect();
for (idx, ch) in text.char_indices() {
if !invisible_positions.contains_key(&idx) {
result.push(ch);
}
}
result
}
}
#[derive(Debug, Clone)]
struct InvisibleMatch {
position: usize,
character: char,
char_type: InvisibleCharType,
name: String,
}
#[async_trait]
impl Scanner for InvisibleText {
fn name(&self) -> &str {
"InvisibleText"
}
async fn scan(&self, input: &str, _vault: &Vault) -> Result<ScanResult> {
let matches = self.detect_invisible_chars(input);
if matches.is_empty() {
return Ok(ScanResult::pass(input.to_string()));
}
let risk_score = self.calculate_risk_score(&matches, input.len());
if risk_score < self.config.threshold {
return Ok(ScanResult::pass(input.to_string()));
}
let entities: Vec<Entity> = matches
.iter()
.map(|m| {
let mut metadata = HashMap::new();
metadata.insert("char_type".to_string(), m.char_type.as_str().to_string());
metadata.insert("unicode_name".to_string(), m.name.clone());
metadata.insert("unicode_value".to_string(), format!("U+{:04X}", m.character as u32));
Entity {
entity_type: "invisible_character".to_string(),
text: format!("[{}]", m.name),
start: m.position,
end: m.position + m.character.len_utf8(),
confidence: m.char_type.confidence(),
metadata,
}
})
.collect();
let description = format!("Found {} invisible character(s)", matches.len());
let risk_factor = RiskFactor::new(
"invisible_characters",
&description,
if risk_score >= 0.7 {
Severity::High
} else if risk_score >= 0.4 {
Severity::Medium
} else {
Severity::Low
},
risk_score,
);
let sanitized_text = self.sanitize_text(input, &matches);
let mut result = ScanResult::new(sanitized_text, false, risk_score)
.with_risk_factor(risk_factor)
.with_metadata("invisible_count", matches.len())
.with_metadata("invisible_density", (matches.len() as f32 / input.len().max(1) as f32).to_string());
for entity in entities {
result = result.with_entity(entity);
}
Ok(result)
}
fn scanner_type(&self) -> ScannerType {
ScannerType::Input
}
fn description(&self) -> &str {
"Detects hidden/invisible Unicode characters in input text"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_invisible_text_zero_width_space() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "Hello\u{200B}World";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.risk_score > 0.0);
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity_type, "invisible_character");
}
#[tokio::test]
async fn test_invisible_text_multiple_zero_width() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "Test\u{200B}with\u{200C}multiple\u{200D}invisible";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
assert_eq!(result.entities.len(), 3);
}
#[tokio::test]
async fn test_invisible_text_direction_marks() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "Hello\u{202E}World";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.entities.iter().any(|e|
e.metadata.get("char_type").map(|s| s.as_str()) == Some("direction_mark")
));
}
#[tokio::test]
async fn test_invisible_text_control_characters() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "Alert\u{0007}message";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
}
#[tokio::test]
async fn test_invisible_text_clean() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let clean_text = "This is normal text with spaces and newlines\nNo invisible characters";
let result = scanner.scan(clean_text, &vault).await.unwrap();
assert!(result.is_valid);
assert_eq!(result.risk_score, 0.0);
assert!(result.entities.is_empty());
}
#[tokio::test]
async fn test_invisible_text_removal() {
let config = InvisibleTextConfig {
threshold: 0.1,
remove: true,
..Default::default()
};
let scanner = InvisibleText::new(config).unwrap();
let vault = Vault::new();
let text = "Hello\u{200B}World\u{200C}Test";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
assert_eq!(result.sanitized_text, "HelloWorldTest");
}
#[tokio::test]
async fn test_invisible_text_threshold() {
let config = InvisibleTextConfig {
threshold: 0.5, ..Default::default()
};
let scanner = InvisibleText::new(config).unwrap();
let vault = Vault::new();
let text = format!("This is a very long text with lots of words\u{200B} and only one invisible character to make the density very low");
let result = scanner.scan(&text, &vault).await.unwrap();
assert!(result.is_valid || result.risk_score < 0.5);
}
#[tokio::test]
async fn test_invisible_text_high_density() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "A\u{200B}B\u{200C}C\u{200D}D";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(!result.is_valid);
assert!(result.risk_score > 0.5);
}
#[tokio::test]
async fn test_invisible_text_selective_detection() {
let config = InvisibleTextConfig {
threshold: 0.1,
remove: false,
detect_zero_width: true,
detect_control: false,
detect_direction_marks: false,
detect_non_printable: false,
};
let scanner = InvisibleText::new(config).unwrap();
let vault = Vault::new();
let text1 = "Test\u{200B}zero";
let result1 = scanner.scan(text1, &vault).await.unwrap();
assert!(!result1.is_valid);
let text2 = "Test\u{202E}direction";
let result2 = scanner.scan(text2, &vault).await.unwrap();
assert!(result2.is_valid);
}
#[tokio::test]
async fn test_invisible_text_normal_whitespace() {
let scanner = InvisibleText::default_config().unwrap();
let vault = Vault::new();
let text = "Normal text with\nlines and\ttabs and spaces";
let result = scanner.scan(text, &vault).await.unwrap();
assert!(result.is_valid);
}
}