1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
//! Opt-in input sanitizing for terminal-control bytes.
//!
//! Core `rich` keeps ESC for upstream parity. This extension-owned helper is
//! for CLI boundaries that need to display untrusted text without allowing that
//! text to move the cursor, clear the screen, or open terminal hyperlinks.
/// Replace terminal controls with visible, inert text.
///
/// LF and TAB are preserved because they are content/layout in plain text, CSV
/// and TSV. ESC and C1 controls are rendered as printable text before the core
/// renderer sees them, so the renderer's own ANSI styling remains unaffected.
pub fn sanitize_terminal_controls(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\n' | '\t' => out.push(ch),
'\u{1b}' => out.push('␛'),
'\u{7f}' => out.push('␡'),
'\0'..='\u{1f}' => {
let picture = char::from_u32(0x2400 + ch as u32).expect("control picture");
out.push(picture);
}
'\u{80}'..='\u{9f}' => {
out.push_str(&format!("\\u{{{:04X}}}", ch as u32));
}
_ => out.push(ch),
}
}
out
}
/// Neutralise terminal controls in ANSI-styled text while keeping what the
/// ANSI decoder (`Text::from_ansi`) turns into styles.
///
/// CSI sequences (`ESC [ … final`) and two-character escapes pass through:
/// the decoder applies SGR colours and drops the rest. OSC strings (titles,
/// clipboard writes, hyperlinks) are removed. Every other control — a lone
/// ESC the decoder would leave in the text, C1 controls such as U+009B, BEL
/// and the other C0 controls — is made visible as `sanitize_terminal_controls`
/// shows it. Newlines, tabs and carriage returns are kept for the decoder's
/// line handling.
pub fn sanitize_ansi_for_decoder(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch != '\u{1b}' {
match ch {
'\n' | '\t' | '\r' => out.push(ch),
'\0'..='\u{1f}' | '\u{7f}'..='\u{9f}' => {
out.push_str(&sanitize_terminal_controls(ch.encode_utf8(&mut [0; 4])))
}
_ => out.push(ch),
}
i += 1;
continue;
}
match chars.get(i + 1).copied() {
// OSC: drop through its terminator on this line (ST or BEL), or
// just the introducer when it has none.
Some(']') => {
let mut j = i + 2;
let mut end = None;
while j < chars.len() && chars[j] != '\n' {
if chars[j] == '\u{7}' {
end = Some(j + 1);
break;
}
if chars[j] == '\u{1b}' && chars.get(j + 1) == Some(&'\\') {
end = Some(j + 2);
break;
}
j += 1;
}
i = end.unwrap_or(i + 2);
}
Some('[') => {
// ESC [ params(0x30-0x3f)* intermediates(0x20-0x2f)* final(0x40-0x7e)
let mut j = i + 2;
while j < chars.len() && ('\u{30}'..='\u{3f}').contains(&chars[j]) {
j += 1;
}
while j < chars.len() && ('\u{20}'..='\u{2f}').contains(&chars[j]) {
j += 1;
}
if j < chars.len() && ('\u{40}'..='\u{7e}').contains(&chars[j]) {
out.extend(&chars[i..=j]);
i = j + 1;
} else {
out.push('␛');
i += 1;
}
}
// Two-character escapes the decoder consumes and drops.
Some(c) if c == '(' || ('@'..='Z').contains(&c) || ('\\'..='_').contains(&c) => {
i += 2;
}
_ => {
out.push('␛');
i += 1;
}
}
}
out
}
/// Whether `c` is a bidirectional formatting control: ALM (U+061C), LRM and
/// RLM (U+200E, U+200F), the embeddings and overrides U+202A–U+202E and the
/// isolates U+2066–U+2069. They print nothing but reorder the text around
/// them, which lets one string display as another ("Trojan Source").
pub fn is_bidi_control(c: char) -> bool {
matches!(
c as u32,
0x061c | 0x200e | 0x200f | 0x202a..=0x202e | 0x2066..=0x2069
)
}
/// [`sanitize_terminal_controls`], and bidi controls (see
/// [`is_bidi_control`]) shown as `\u{202E}` escapes too.
///
/// A separate function because [`sanitize_terminal_controls`] promises to
/// leave everything but terminal controls alone.
pub fn sanitize_terminal_and_bidi_controls(input: &str) -> String {
escape_bidi(&sanitize_terminal_controls(input))
}
/// Text for a one-line label (a file name, a test name): like
/// [`sanitize_terminal_and_bidi_controls`], but LF and TAB become visible
/// too (`␊`, `␉`), so the label cannot break or push the line it sits on.
pub fn sanitize_single_line(input: &str) -> String {
sanitize_terminal_and_bidi_controls(input)
.replace('\n', "␊")
.replace('\t', "␉")
}
fn escape_bidi(input: &str) -> String {
if !input.chars().any(is_bidi_control) {
return input.to_string();
}
let mut out = String::with_capacity(input.len() + 8);
for ch in input.chars() {
if is_bidi_control(ch) {
out.push_str(&format!("\\u{{{:04X}}}", ch as u32));
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
sanitize_single_line, sanitize_terminal_and_bidi_controls, sanitize_terminal_controls,
};
#[test]
fn bidi_controls_are_escaped_only_by_the_bidi_variants() {
let evil = "a\u{202e}b\u{2066}\u{61c}\x1b";
assert_eq!(
sanitize_terminal_controls(evil),
"a\u{202e}b\u{2066}\u{61c}␛"
);
assert_eq!(
sanitize_terminal_and_bidi_controls(evil),
"a\\u{202E}b\\u{2066}\\u{061C}␛"
);
assert_eq!(sanitize_single_line("a\tb\nc\u{200f}"), "a␉b␊c\\u{200F}");
}
#[test]
fn esc_and_csi_are_visible_not_executable() {
assert_eq!(sanitize_terminal_controls("a\x1b[2Jb"), "a␛[2Jb");
assert_eq!(sanitize_terminal_controls("a\u{9b}2Jb"), "a\\u{009B}2Jb");
}
#[test]
fn preserves_layout_controls() {
assert_eq!(sanitize_terminal_controls("a\tb\nc"), "a\tb\nc");
}
#[test]
fn renders_other_c0_controls_as_control_pictures() {
assert_eq!(sanitize_terminal_controls("a\u{7}b\rc"), "a␇b␍c");
}
}