Skip to main content

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}