datui_lib/sanitize.rs
1//! Keeping untrusted text from becoming terminal commands.
2//!
3//! datui displays text it did not write: cell values, column names, filenames,
4//! and parser error messages all originate in whatever file the user opened.
5//! A terminal does not distinguish text from commands, so a cell containing
6//! `\x1b]52;c;...\x07` is a clipboard write, and one containing `\x1b[2J` wipes
7//! the screen. That is the standard vulnerability class for any program that
8//! renders foreign text.
9//!
10//! ratatui defends against this in [`Buffer::set_stringn`], which drops
11//! graphemes containing control characters. It does **not** defend against it
12//! in `Span` and `Line` rendering, which is what almost everything actually
13//! uses: `Span::render_ref` appends zero-width graphemes to the preceding cell,
14//! and an ESC is zero-width. The crossterm backend then writes each cell symbol
15//! out with `Print`, unfiltered, and the escape reaches the terminal intact.
16//!
17//! Sanitising at the roughly two hundred places that build a `Span` would work
18//! until someone adds the two hundred and first. So the sweep happens once, at
19//! the end of `App::render`, over the finished buffer. Every path into the
20//! screen has converged by then, including paths added later and paths nobody
21//! remembered to audit.
22//!
23//! [`Buffer::set_stringn`]: ratatui::buffer::Buffer::set_stringn
24
25use ratatui::buffer::Buffer;
26
27/// What a stripped control character is replaced with.
28///
29/// A visible marker rather than deletion: silently dropping bytes would let a
30/// hostile value disguise itself as a different, plausible value. Seeing
31/// `total: 1<?>0` is a hint that something is wrong with the data, where
32/// `total: 10` is a lie.
33const REPLACEMENT: char = '\u{fffd}';
34
35/// True for characters that must never reach the terminal from untrusted text.
36///
37/// The C0 controls, DEL, and the C1 range, which some terminals accept as
38/// single-byte equivalents of the two-byte escape sequences (`\u{009b}` for
39/// CSI, for instance). Tab is allowed through: it is layout rather than
40/// control, and ratatui may place one legitimately.
41#[inline]
42fn is_forbidden(c: char) -> bool {
43 let n = c as u32;
44 (n < 0x20 && c != '\t') || n == 0x7f || (0x80..=0x9f).contains(&n)
45}
46
47/// Returns a display-safe copy of `s`, or `None` if it was already safe.
48///
49/// The `None` case is the common one by a wide margin, and returning it avoids
50/// allocating for the overwhelming majority of cells.
51pub fn sanitized(s: &str) -> Option<String> {
52 if !s.chars().any(is_forbidden) {
53 return None;
54 }
55 Some(
56 s.chars()
57 .map(|c| if is_forbidden(c) { REPLACEMENT } else { c })
58 .collect(),
59 )
60}
61
62/// Replaces control characters in every cell of a finished buffer.
63///
64/// Call this once, after all rendering, and before the buffer is handed to a
65/// backend. It is the last point at which datui controls what the terminal
66/// receives.
67pub fn sanitize_buffer(buf: &mut Buffer) {
68 for cell in buf.content.iter_mut() {
69 if let Some(clean) = sanitized(cell.symbol()) {
70 cell.set_symbol(&clean);
71 }
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78 use ratatui::layout::Rect;
79
80 #[test]
81 fn leaves_ordinary_text_alone() {
82 assert_eq!(sanitized("hello"), None);
83 assert_eq!(sanitized(""), None);
84 // Tab is layout, not control.
85 assert_eq!(sanitized("a\tb"), None);
86 // Non-ASCII text must survive untouched.
87 assert_eq!(sanitized("héllo · 日本語 · 🦀"), None);
88 }
89
90 #[test]
91 fn replaces_c0_controls() {
92 assert_eq!(sanitized("\x1b[2J").unwrap(), "\u{fffd}[2J");
93 assert_eq!(sanitized("a\x07b").unwrap(), "a\u{fffd}b");
94 assert_eq!(sanitized("a\rb").unwrap(), "a\u{fffd}b");
95 assert_eq!(sanitized("a\nb").unwrap(), "a\u{fffd}b");
96 assert_eq!(sanitized("a\x00b").unwrap(), "a\u{fffd}b");
97 }
98
99 #[test]
100 fn replaces_del_and_c1() {
101 assert_eq!(sanitized("a\x7fb").unwrap(), "a\u{fffd}b");
102 // Single-byte CSI, accepted by some terminals.
103 assert_eq!(sanitized("\u{009b}31m").unwrap(), "\u{fffd}31m");
104 assert_eq!(sanitized("\u{0080}").unwrap(), "\u{fffd}");
105 assert_eq!(sanitized("\u{009f}").unwrap(), "\u{fffd}");
106 }
107
108 #[test]
109 fn keeps_the_character_after_the_c1_range() {
110 // U+00A0 is a no-break space, not a control. Off-by-one guard.
111 assert_eq!(sanitized("\u{00a0}"), None);
112 }
113
114 #[test]
115 fn sweeps_a_whole_buffer() {
116 let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
117 buf[(0, 0)].set_symbol("a");
118 buf[(1, 0)].set_symbol("\x1b");
119 // A cell symbol can hold several graphemes: Span rendering appends
120 // zero-width ones to the preceding cell, which is how an escape gets
121 // in alongside a visible character in the first place.
122 buf[(2, 0)].set_symbol("b\x1b]52;c;x\x07");
123 buf[(3, 0)].set_symbol("c");
124
125 sanitize_buffer(&mut buf);
126
127 assert_eq!(buf[(0, 0)].symbol(), "a");
128 assert_eq!(buf[(1, 0)].symbol(), "\u{fffd}");
129 assert_eq!(buf[(2, 0)].symbol(), "b\u{fffd}]52;c;x\u{fffd}");
130 assert_eq!(buf[(3, 0)].symbol(), "c");
131 }
132}