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), and the word joiner. Kept as a small
16/// hand-curated set so this stays a std-only helper — a full Unicode-Cf gate
17/// would need an extra crate for a benefit no legitimate identifier needs.
18const DANGEROUS_CODEPOINTS: &[char] = &[
19 '\u{061C}', // ARABIC LETTER MARK
20 '\u{200B}', // ZERO WIDTH SPACE
21 '\u{200C}', // ZERO WIDTH NON-JOINER
22 '\u{200D}', // ZERO WIDTH JOINER
23 '\u{200E}', // LEFT-TO-RIGHT MARK
24 '\u{200F}', // RIGHT-TO-LEFT MARK
25 '\u{202A}', // LEFT-TO-RIGHT EMBEDDING
26 '\u{202B}', // RIGHT-TO-LEFT EMBEDDING
27 '\u{202C}', // POP DIRECTIONAL FORMATTING
28 '\u{202D}', // LEFT-TO-RIGHT OVERRIDE
29 '\u{202E}', // RIGHT-TO-LEFT OVERRIDE (the classic "reverse the string" glyph)
30 '\u{2060}', // WORD JOINER
31 '\u{2066}', // LEFT-TO-RIGHT ISOLATE
32 '\u{2067}', // RIGHT-TO-LEFT ISOLATE
33 '\u{2068}', // FIRST STRONG ISOLATE
34 '\u{2069}', // POP DIRECTIONAL ISOLATE
35 '\u{FEFF}', // ZERO WIDTH NO-BREAK SPACE / BOM
36];
37
38/// True if `s` carries any character that visually reorders or hides
39/// surrounding text (Trojan-Source-class spoofing).
40pub fn contains_bidi_or_zero_width(s: &str) -> bool {
41 s.chars().any(|c| DANGEROUS_CODEPOINTS.contains(&c))
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47
48 #[test]
49 fn plain_ascii_and_common_utf8_pass() {
50 for s in ["", "hello", "agent:test", "réseau", "支付", "abc\u{1F600}xyz"] {
51 assert!(!contains_bidi_or_zero_width(s), "wrong reject: {s:?}");
52 }
53 }
54
55 /// Every dangerous code point flags whether it appears alone, at the
56 /// start, in the middle, at the end, or inside legitimate UTF-8.
57 #[test]
58 fn every_dangerous_code_point_is_detected_in_every_position() {
59 for &c in DANGEROUS_CODEPOINTS {
60 for position in [
61 format!("{c}"),
62 format!("{c}legit"),
63 format!("le{c}git"),
64 format!("legit{c}"),
65 format!("réseau{c}pay"),
66 ] {
67 assert!(
68 contains_bidi_or_zero_width(&position),
69 "missed U+{:04X} in {position:?}",
70 c as u32,
71 );
72 }
73 }
74 }
75
76 /// The infamous Trojan Source RLO attack: `admin\u{202E}nimda` looks
77 /// like two admin fields on a terminal but is one hostile identifier.
78 #[test]
79 fn trojan_source_admin_swap_is_detected() {
80 let hostile = "admin\u{202E}nimda";
81 assert!(contains_bidi_or_zero_width(hostile));
82 // Sanity: the raw bytes and the visible glyphs disagree — the whole
83 // point of the attack, and the whole point of blocking the guard.
84 assert_ne!(hostile.len(), "adminnimda".len());
85 }
86}