Skip to main content

datui_lib/
sanitize.rs

1//! Keeping untrusted text (cells, column names, filenames, parser errors) from becoming
2//! terminal commands: `\x1b]52;c;...\x07` in a cell writes the clipboard, `\x1b[2J` wipes
3//! the screen. ratatui's [`Buffer::set_stringn`] drops control graphemes, but `Span`
4//! and `Line` rendering append zero-width graphemes (ESC included) to the previous cell,
5//! and crossterm prints cells unfiltered. Rather than guard every `Span` site, one sweep
6//! runs at the end of `App::render` over the finished buffer.
7//!
8//! [`Buffer::set_stringn`]: ratatui::buffer::Buffer::set_stringn
9
10use ratatui::buffer::Buffer;
11
12/// What a stripped control character becomes: a visible marker, since deleting it
13/// would let a hostile value pose as a plausible one (`1<?>0` vs `10`).
14const REPLACEMENT: char = '\u{fffd}';
15
16/// Characters that must never reach the terminal from untrusted text: C0 controls, DEL
17/// and C1 (some terminals take `\u{009b}` as CSI). Tab is layout and allowed.
18#[inline]
19fn is_forbidden(c: char) -> bool {
20    let n = c as u32;
21    (n < 0x20 && c != '\t') || n == 0x7f || (0x80..=0x9f).contains(&n)
22}
23
24/// A display-safe copy of `s`, or `None` if already safe (the usual case, sparing an
25/// allocation).
26pub fn sanitized(s: &str) -> Option<String> {
27    if !s.chars().any(is_forbidden) {
28        return None;
29    }
30    Some(
31        s.chars()
32            .map(|c| if is_forbidden(c) { REPLACEMENT } else { c })
33            .collect(),
34    )
35}
36
37/// Replace control characters in every cell of a finished buffer: once, after all
38/// rendering, before the backend, the last point datui controls the output.
39pub fn sanitize_buffer(buf: &mut Buffer) {
40    for cell in buf.content.iter_mut() {
41        if let Some(clean) = sanitized(cell.symbol()) {
42            cell.set_symbol(&clean);
43        }
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use ratatui::layout::Rect;
51
52    #[test]
53    fn leaves_ordinary_text_alone() {
54        assert_eq!(sanitized("hello"), None);
55        assert_eq!(sanitized(""), None);
56        // Tab is layout, not control.
57        assert_eq!(sanitized("a\tb"), None);
58        // Non-ASCII text must survive untouched.
59        assert_eq!(sanitized("héllo · 日本語 · 🦀"), None);
60    }
61
62    #[test]
63    fn replaces_c0_controls() {
64        assert_eq!(sanitized("\x1b[2J").unwrap(), "\u{fffd}[2J");
65        assert_eq!(sanitized("a\x07b").unwrap(), "a\u{fffd}b");
66        assert_eq!(sanitized("a\rb").unwrap(), "a\u{fffd}b");
67        assert_eq!(sanitized("a\nb").unwrap(), "a\u{fffd}b");
68        assert_eq!(sanitized("a\x00b").unwrap(), "a\u{fffd}b");
69    }
70
71    #[test]
72    fn replaces_del_and_c1() {
73        assert_eq!(sanitized("a\x7fb").unwrap(), "a\u{fffd}b");
74        // Single-byte CSI, accepted by some terminals.
75        assert_eq!(sanitized("\u{009b}31m").unwrap(), "\u{fffd}31m");
76        assert_eq!(sanitized("\u{0080}").unwrap(), "\u{fffd}");
77        assert_eq!(sanitized("\u{009f}").unwrap(), "\u{fffd}");
78    }
79
80    #[test]
81    fn keeps_the_character_after_the_c1_range() {
82        // U+00A0 is a no-break space, not a control. Off-by-one guard.
83        assert_eq!(sanitized("\u{00a0}"), None);
84    }
85
86    #[test]
87    fn sweeps_a_whole_buffer() {
88        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
89        buf[(0, 0)].set_symbol("a");
90        buf[(1, 0)].set_symbol("\x1b");
91        // A cell symbol can hold several graphemes: Span rendering appends
92        // zero-width ones to the preceding cell, which is how an escape gets
93        // in alongside a visible character in the first place.
94        buf[(2, 0)].set_symbol("b\x1b]52;c;x\x07");
95        buf[(3, 0)].set_symbol("c");
96
97        sanitize_buffer(&mut buf);
98
99        assert_eq!(buf[(0, 0)].symbol(), "a");
100        assert_eq!(buf[(1, 0)].symbol(), "\u{fffd}");
101        assert_eq!(buf[(2, 0)].symbol(), "b\u{fffd}]52;c;x\u{fffd}");
102        assert_eq!(buf[(3, 0)].symbol(), "c");
103    }
104}