chio_guards/text_utils.rs
1//! Text canonicalization utilities shared across content-safety guards.
2//!
3//! These functions normalize free-form text before running regex-based signal
4//! detection. Canonicalization is deliberately conservative: the output is a
5//! lowercase ASCII-biased form that preserves the general shape of the input
6//! but strips common obfuscation techniques (zero-width splicing, homoglyph
7//! substitution, punctuation runs, case flipping).
8//!
9//! This module is shared infrastructure for the
10//! [`crate::prompt_injection::PromptInjectionGuard`] and the
11//! [`crate::jailbreak::JailbreakGuard`]. It has no external dependencies beyond
12//! the standard library and is safe to use in fail-closed guard paths.
13
14/// The canonical-form representation of an input string.
15///
16/// The returned `String` has:
17///
18/// - all ASCII letters lowercased;
19/// - common Unicode homoglyphs of Latin letters folded to their ASCII
20/// counterparts (e.g. Cyrillic `а` -> `a`, full-width digits -> ASCII);
21/// - zero-width and Unicode formatting characters removed;
22/// - runs of two or more separator-class punctuation characters collapsed
23/// to a single space.
24///
25/// This is NOT a security-grade Unicode normaliser. It is a best-effort
26/// heuristic that defeats the most common copy-paste prompt injection
27/// tricks seen in the wild. Callers still need to bound the input length
28/// (`max_scan_bytes`) and fail-closed on internal errors.
29pub fn canonicalize(input: &str) -> String {
30 // First pass: strip zero-width / format characters, fold homoglyphs,
31 // lowercase ASCII letters in one sweep.
32 let mut out = String::with_capacity(input.len());
33 for ch in input.chars() {
34 if is_zero_width(ch) {
35 continue;
36 }
37 let mapped = fold_homoglyph(ch);
38 // Lowercase only for ASCII letters; leave folded ASCII as-is.
39 if mapped.is_ascii_uppercase() {
40 out.push(mapped.to_ascii_lowercase());
41 } else {
42 out.push(mapped);
43 }
44 }
45
46 // Second pass: collapse whitespace runs and separator-punctuation runs
47 // to a single space, and trim the result.
48 collapse_runs(&out)
49}
50
51/// Return true if `ch` is a zero-width or Unicode formatting character
52/// commonly used to obfuscate prompt content.
53///
54/// The set is a subset of the Unicode "formatting / joining" category plus a
55/// handful of BOM/LRM/RLM codepoints; it is not exhaustive but covers the
56/// characters that appear in observed injection payloads.
57pub fn is_zero_width(ch: char) -> bool {
58 matches!(
59 ch,
60 '\u{200B}' // ZERO WIDTH SPACE
61 | '\u{200C}' // ZWNJ
62 | '\u{200D}' // ZWJ
63 | '\u{200E}' // LRM
64 | '\u{200F}' // RLM
65 | '\u{202A}'..='\u{202E}' // LRE/RLE/PDF/LRO/RLO
66 | '\u{2060}' // WORD JOINER
67 | '\u{2061}'..='\u{2064}' // invisible function/plus/separator
68 | '\u{FEFF}' // BOM / zero-width no-break space
69 | '\u{180E}' // Mongolian vowel separator
70 | '\u{034F}' // combining grapheme joiner
71 | '\u{061C}' // arabic letter mark
72 )
73}
74
75/// Fold a single character to its ASCII analogue when it is a commonly-used
76/// homoglyph. Returns the original character when no fold is known.
77///
78/// The table is intentionally small: we prioritise characters that actually
79/// appear in observed prompt-injection payloads (Cyrillic letters that look
80/// like Latin, full-width digits and letters, Greek alpha/omicron, etc.).
81fn fold_homoglyph(ch: char) -> char {
82 match ch {
83 // Cyrillic -> Latin look-alikes.
84 'А' => 'A',
85 'а' => 'a',
86 'В' => 'B',
87 'С' => 'C',
88 'с' => 'c',
89 'Е' => 'E',
90 'е' => 'e',
91 'Н' => 'H',
92 'К' => 'K',
93 'М' => 'M',
94 'О' => 'O',
95 'о' => 'o',
96 'Р' => 'P',
97 'р' => 'p',
98 'Т' => 'T',
99 'Х' => 'X',
100 'х' => 'x',
101 'У' => 'Y',
102 'у' => 'y',
103 'і' => 'i',
104 'І' => 'I',
105 // Greek -> Latin look-alikes.
106 'Α' => 'A',
107 'α' => 'a',
108 'Β' => 'B',
109 'Ε' => 'E',
110 'ε' => 'e',
111 'Η' => 'H',
112 'Ι' => 'I',
113 'ι' => 'i',
114 'Κ' => 'K',
115 'Μ' => 'M',
116 'Ν' => 'N',
117 'Ο' => 'O',
118 'ο' => 'o',
119 'Ρ' => 'P',
120 'Τ' => 'T',
121 'Υ' => 'Y',
122 'Χ' => 'X',
123 // Full-width ASCII -> ASCII.
124 '\u{FF01}'..='\u{FF5E}' => {
125 // Full-width punctuation and Latin block maps directly via offset.
126 // SAFETY: the subtraction stays inside the BMP; every codepoint
127 // in the range has a valid ASCII analogue at offset 0xFEE0.
128 let raw = ch as u32 - 0xFEE0;
129 char::from_u32(raw).unwrap_or(ch)
130 }
131 // Full-width digits 0-9 handled by the FF01-FF5E range above.
132 _ => ch,
133 }
134}
135
136/// Collapse runs of whitespace and separator punctuation into a single space,
137/// then trim leading/trailing whitespace. This prevents attackers from
138/// evading regex matchers by splicing extra punctuation into key phrases.
139fn collapse_runs(input: &str) -> String {
140 let mut out = String::with_capacity(input.len());
141 let mut prev_was_break = false;
142 for ch in input.chars() {
143 let is_break = ch.is_whitespace() || is_separator_punct(ch);
144 if is_break {
145 if !prev_was_break && !out.is_empty() {
146 out.push(' ');
147 }
148 prev_was_break = true;
149 } else {
150 out.push(ch);
151 prev_was_break = false;
152 }
153 }
154 let trimmed = out.trim_end().to_string();
155 trimmed
156}
157
158/// ASCII-centric separator punctuation run detector. We collapse runs of
159/// these so "ignore---all---previous" normalises cleanly. We do NOT collapse
160/// single punctuation characters: only runs of two or more are affected by
161/// `collapse_runs`.
162///
163/// Note: `:` and `/` are intentionally excluded so URL-shaped substrings
164/// (`https://`) survive canonicalization and remain matchable by the
165/// exfiltration-framing signal.
166fn is_separator_punct(ch: char) -> bool {
167 matches!(
168 ch,
169 '-' | '_' | '~' | '=' | '*' | '+' | '.' | ',' | ';' | '|' | '\\'
170 )
171}
172
173/// Truncate `input` to at most `max_bytes` bytes while preserving UTF-8
174/// boundaries. Returns the truncated slice and a `bool` indicating whether
175/// truncation happened. Guards use this to bound scan cost without splitting
176/// multi-byte characters.
177pub fn truncate_at_char_boundary(input: &str, max_bytes: usize) -> (&str, bool) {
178 if input.len() <= max_bytes {
179 return (input, false);
180 }
181 // Walk backwards from max_bytes to the nearest char boundary.
182 let mut end = max_bytes.min(input.len());
183 while end > 0 && !input.is_char_boundary(end) {
184 end -= 1;
185 }
186 (&input[..end], true)
187}
188
189/// Ratio of non-alphanumeric (punctuation / symbol) characters to
190/// non-whitespace characters. Used by the statistical jailbreak layer to
191/// flag inputs whose visible content is dominated by symbols (a common
192/// adversarial-suffix shape). Returns `0.0` for empty or all-whitespace
193/// input.
194pub fn punctuation_ratio(s: &str) -> f32 {
195 let mut punct = 0usize;
196 let mut total = 0usize;
197 for c in s.chars() {
198 if c.is_whitespace() {
199 continue;
200 }
201 total += 1;
202 if !c.is_alphanumeric() {
203 punct += 1;
204 }
205 }
206 if total == 0 {
207 0.0
208 } else {
209 punct as f32 / total as f32
210 }
211}
212
213/// Return true if `s` contains a run of `min_run` or more consecutive
214/// non-alphanumeric, non-whitespace characters. Adversarial suffixes in the
215/// wild typically appear as long unbroken punctuation / symbol sequences.
216pub fn long_run_of_symbols(s: &str, min_run: usize) -> bool {
217 if min_run == 0 {
218 return true;
219 }
220 let mut run = 0usize;
221 for c in s.chars() {
222 if c.is_alphanumeric() || c.is_whitespace() {
223 run = 0;
224 continue;
225 }
226 run += 1;
227 if run >= min_run {
228 return true;
229 }
230 }
231 false
232}
233
234/// Shannon entropy (bits/char) over non-whitespace ASCII bytes of `s`.
235/// Returns `0.0` when the ASCII-non-whitespace subset is empty. This is a
236/// cheap proxy for character diversity: payloads dominated by a handful of
237/// symbols have low entropy; uniform-random adversarial suffixes have high
238/// entropy. Non-ASCII characters are ignored (they are already accounted
239/// for by canonicalization folding).
240pub fn shannon_entropy_ascii_nonws(s: &str) -> f32 {
241 let mut counts = [0u32; 128];
242 let mut total = 0u32;
243 for b in s.bytes() {
244 if b >= 128 || b.is_ascii_whitespace() {
245 continue;
246 }
247 counts[b as usize] = counts[b as usize].saturating_add(1);
248 total = total.saturating_add(1);
249 }
250 if total == 0 {
251 return 0.0;
252 }
253 let total_f = total as f64;
254 let mut entropy = 0.0f64;
255 for c in counts {
256 if c == 0 {
257 continue;
258 }
259 let p = (c as f64) / total_f;
260 entropy -= p * p.log2();
261 }
262 entropy as f32
263}
264
265/// Number of zero-width / Unicode formatting codepoints in `s` (using the
266/// [`is_zero_width`] predicate). Useful for a statistical "obfuscation"
267/// signal that fires even when canonicalization has already stripped the
268/// characters: callers count on the original pre-canonicalization string.
269pub fn zero_width_count(s: &str) -> usize {
270 s.chars().filter(|c| is_zero_width(*c)).count()
271}
272
273/// Ratio of distinct character shingles (sliding n-grams) to total shingles
274/// for `s` after canonicalization. Lower values indicate heavy repetition
275/// (a hallmark of token-spam / adversarial-suffix attacks). Returns `1.0`
276/// when `s` has fewer than `n` chars or is empty (nothing to compare).
277///
278/// `n` is clamped to `[1, 16]`; callers typically pick `n = 3` for
279/// character trigrams, which balance sensitivity against random noise.
280pub fn shingle_uniqueness(s: &str, n: usize) -> f32 {
281 let n = n.clamp(1, 16);
282 let chars: Vec<char> = s.chars().collect();
283 if chars.len() < n {
284 return 1.0;
285 }
286 let total = chars.len() - n + 1;
287 if total == 0 {
288 return 1.0;
289 }
290 let mut seen: std::collections::HashSet<String> =
291 std::collections::HashSet::with_capacity(total);
292 for window in chars.windows(n) {
293 let key: String = window.iter().collect();
294 seen.insert(key);
295 }
296 (seen.len() as f32) / (total as f32)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn canonicalize_lowercases_ascii() {
305 assert_eq!(canonicalize("IGNORE ALL"), "ignore all");
306 }
307
308 #[test]
309 fn canonicalize_strips_zero_width() {
310 let sneaky = "ig\u{200B}no\u{200C}re all";
311 assert_eq!(canonicalize(sneaky), "ignore all");
312 }
313
314 #[test]
315 fn canonicalize_folds_homoglyphs() {
316 // Cyrillic U+0440 (er) -> ASCII "p"; lowercase and fold together.
317 let disguised = "igno\u{0440}e";
318 assert_eq!(canonicalize(disguised), "ignope");
319 // Full-width ASCII folds via the 0xFEE0 offset.
320 assert_eq!(canonicalize("IGNORE"), "ignore");
321 }
322
323 #[test]
324 fn canonicalize_collapses_separators() {
325 assert_eq!(
326 canonicalize("ignore---all___previous"),
327 "ignore all previous"
328 );
329 }
330
331 #[test]
332 fn truncate_respects_utf8_boundary() {
333 let input = "héllo"; // é is two bytes
334 let (out, truncated) = truncate_at_char_boundary(input, 2);
335 assert!(truncated);
336 assert_eq!(out, "h");
337 }
338
339 #[test]
340 fn truncate_short_input_unchanged() {
341 let (out, truncated) = truncate_at_char_boundary("hi", 100);
342 assert!(!truncated);
343 assert_eq!(out, "hi");
344 }
345
346 #[test]
347 fn punctuation_ratio_basic() {
348 assert_eq!(punctuation_ratio(""), 0.0);
349 assert_eq!(punctuation_ratio(" \n\t"), 0.0);
350 // All alphanum -> 0.0.
351 assert_eq!(punctuation_ratio("abc123"), 0.0);
352 // All punctuation -> 1.0.
353 assert_eq!(punctuation_ratio("!!!@@@"), 1.0);
354 // Half and half (non-whitespace): 3/6 = 0.5.
355 assert!((punctuation_ratio("ab;c;!") - 0.5).abs() < 1e-6);
356 }
357
358 #[test]
359 fn long_run_of_symbols_detects_runs() {
360 assert!(!long_run_of_symbols("hello world", 12));
361 assert!(long_run_of_symbols("hello !!!!!!!!!!!! world", 12));
362 assert!(!long_run_of_symbols("hello !!! world", 12));
363 // min_run 0 is trivially true even for empty input.
364 assert!(long_run_of_symbols("", 0));
365 }
366
367 #[test]
368 fn shannon_entropy_ascii_nonws_bounds() {
369 // All-one-character -> 0 entropy.
370 assert!(shannon_entropy_ascii_nonws("aaaaaa") < 1e-6);
371 // Two equiprobable characters -> 1 bit.
372 let e = shannon_entropy_ascii_nonws("abababab");
373 assert!((e - 1.0).abs() < 0.1);
374 // Empty input -> 0.
375 assert_eq!(shannon_entropy_ascii_nonws(""), 0.0);
376 }
377
378 #[test]
379 fn zero_width_count_matches_inserts() {
380 let s = "a\u{200B}b\u{200C}c\u{FEFF}d";
381 assert_eq!(zero_width_count(s), 3);
382 assert_eq!(zero_width_count("plain"), 0);
383 }
384
385 #[test]
386 fn shingle_uniqueness_detects_repetition() {
387 // Unique input: every trigram distinct.
388 let u = shingle_uniqueness("abcdefg", 3);
389 assert!((u - 1.0).abs() < 1e-6);
390 // Repeated trigrams: "aaa" repeats.
391 let r = shingle_uniqueness("aaaaaaaaa", 3);
392 assert!(r < 0.2, "expected low uniqueness, got {r}");
393 // Too-short input returns 1.0.
394 assert_eq!(shingle_uniqueness("ab", 3), 1.0);
395 }
396}