av_core/text.rs
1//! Unicode text guards: block characters that let hostile input spoof how
2//! logs, receipts, and identifiers look on a terminal.
3//!
4//! The dangerous set here is the "Trojan Source" family — bidirectional
5//! overrides and zero-width formatting characters — that render invisibly (or
6//! reverse the visible order of surrounding text) while remaining part of the
7//! string's bytes. When those characters ride along in a JWT identity claim
8//! or a tool name, an operator investigating an audit chain sees one thing
9//! while the on-wire bytes say another.
10
11/// Code points that visually reorder or hide surrounding text.
12///
13/// Includes the bidirectional formatting/override/isolate family (U+202A..E,
14/// U+2066..9), the LTR/RTL/Arabic marks (U+061C, U+200E..F), the zero-width
15/// glyphs (U+200B..D, U+2060, U+FEFF), the invisible operators (U+2061..4),
16/// the deprecated shaping controls (U+206A..F), the soft hyphen, the
17/// Mongolian vowel separator, and the line/paragraph separators (which
18/// inject fake line breaks into log views). Kept as a small hand-curated
19/// set so this stays a std-only helper — a full Unicode-Cf gate
20/// would need an extra crate for a benefit no legitimate identifier needs.
21const DANGEROUS_CODEPOINTS: &[char] = &[
22 '\u{00AD}', // SOFT HYPHEN (renders invisibly outside line breaks)
23 '\u{061C}', // ARABIC LETTER MARK
24 '\u{180E}', // MONGOLIAN VOWEL SEPARATOR (invisible, Cf in older Unicode)
25 '\u{200B}', // ZERO WIDTH SPACE
26 '\u{200C}', // ZERO WIDTH NON-JOINER
27 '\u{200D}', // ZERO WIDTH JOINER
28 '\u{200E}', // LEFT-TO-RIGHT MARK
29 '\u{200F}', // RIGHT-TO-LEFT MARK
30 '\u{2028}', // LINE SEPARATOR (injects fake line breaks into log views)
31 '\u{2029}', // PARAGRAPH SEPARATOR
32 '\u{202A}', // LEFT-TO-RIGHT EMBEDDING
33 '\u{202B}', // RIGHT-TO-LEFT EMBEDDING
34 '\u{202C}', // POP DIRECTIONAL FORMATTING
35 '\u{202D}', // LEFT-TO-RIGHT OVERRIDE
36 '\u{202E}', // RIGHT-TO-LEFT OVERRIDE (the classic "reverse the string" glyph)
37 '\u{2060}', // WORD JOINER
38 '\u{2061}', // FUNCTION APPLICATION (invisible operator)
39 '\u{2062}', // INVISIBLE TIMES
40 '\u{2063}', // INVISIBLE SEPARATOR
41 '\u{2064}', // INVISIBLE PLUS
42 '\u{2066}', // LEFT-TO-RIGHT ISOLATE
43 '\u{2067}', // RIGHT-TO-LEFT ISOLATE
44 '\u{2068}', // FIRST STRONG ISOLATE
45 '\u{2069}', // POP DIRECTIONAL ISOLATE
46 '\u{206A}', // INHIBIT SYMMETRIC SWAPPING (deprecated format control)
47 '\u{206B}', // ACTIVATE SYMMETRIC SWAPPING
48 '\u{206C}', // INHIBIT ARABIC FORM SHAPING
49 '\u{206D}', // ACTIVATE ARABIC FORM SHAPING
50 '\u{206E}', // NATIONAL DIGIT SHAPES
51 '\u{206F}', // NOMINAL DIGIT SHAPES
52 '\u{FEFF}', // ZERO WIDTH NO-BREAK SPACE / BOM
53];
54
55/// True if `s` carries any character that visually reorders or hides
56/// surrounding text (Trojan-Source-class spoofing).
57pub fn contains_bidi_or_zero_width(s: &str) -> bool {
58 s.chars().any(is_bidi_or_zero_width)
59}
60
61/// True if `c` is one of the dangerous code points above — the
62/// per-character form for callers that sanitize (replace) rather than
63/// refuse.
64pub fn is_bidi_or_zero_width(c: char) -> bool {
65 DANGEROUS_CODEPOINTS.contains(&c)
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn plain_ascii_and_common_utf8_pass() {
74 for s in ["", "hello", "agent:test", "réseau", "支付", "abc\u{1F600}xyz"] {
75 assert!(!contains_bidi_or_zero_width(s), "wrong reject: {s:?}");
76 }
77 }
78
79 /// Every dangerous code point flags whether it appears alone, at the
80 /// start, in the middle, at the end, or inside legitimate UTF-8.
81 #[test]
82 fn every_dangerous_code_point_is_detected_in_every_position() {
83 for &c in DANGEROUS_CODEPOINTS {
84 for position in [
85 format!("{c}"),
86 format!("{c}legit"),
87 format!("le{c}git"),
88 format!("legit{c}"),
89 format!("réseau{c}pay"),
90 ] {
91 assert!(
92 contains_bidi_or_zero_width(&position),
93 "missed U+{:04X} in {position:?}",
94 c as u32,
95 );
96 }
97 }
98 }
99
100 /// The infamous Trojan Source RLO attack: `admin\u{202E}nimda` looks
101 /// like two admin fields on a terminal but is one hostile identifier.
102 #[test]
103 fn trojan_source_admin_swap_is_detected() {
104 let hostile = "admin\u{202E}nimda";
105 assert!(contains_bidi_or_zero_width(hostile));
106 // Sanity: the raw bytes and the visible glyphs disagree — the whole
107 // point of the attack, and the whole point of blocking the guard.
108 assert_ne!(hostile.len(), "adminnimda".len());
109 }
110}