Skip to main content

netget/utils/
sanitize.rs

1//! Control-character hygiene for text the model supplies.
2//!
3//! Six protocols hand-rolled a version of this before it lived here — `gopher`'s
4//! `sanitize_field`, `finger`'s `strip_controls` and `strip_controls_multiline`, `ident`'s
5//! `sanitize_token`, `whois`'s `sanitize_line_field`, and the `finger` client's own copy. They
6//! were written by copying a neighbour, which is also how `whois` — the one the others were
7//! told to copy — ended up without one at all for a long time.
8//!
9//! **The four variants below are not interchangeable, and the difference is the whole point.**
10//! Picking the wrong one is a silent correctness bug, not a style choice:
11//!
12//! * [`line_field`] maps a control character to a **space**. Use it for a field that occupies
13//!   one line of a structured record — a `Key: value` line, a tab-delimited menu row. Deleting
14//!   the character instead would concatenate the two sides into one word
15//!   (`Good RegistrarRegistrant`), which reads as a single legitimate value and is its own
16//!   small lie. The guarantee is *no forged line*, not that the text is unchanged.
17//! * [`strip_controls`] **removes** them. Use it where the surrounding format has no columns to
18//!   shift, so a deletion cannot be mistaken for content.
19//! * [`multiline`] removes them **except** newline and tab. Use it only for a field whose whole
20//!   purpose is to be free multi-line text (finger's `plan` and `project`).
21//! * [`token`] is [`strip_controls`], then trim, then a **character** bound — for a short
22//!   identifier that also has a length the format cannot exceed.
23//!
24//! [`json_text`] is not a fifth variant of the same thing: it is for pretty-printed JSON shown
25//! as-is, and escapes rather than removes, so the text stays JSON for the same value.
26//!
27//! All four keep non-ASCII text intact: `char::is_control` is Unicode-aware, so this strips
28//! C0, DEL and the C1 range without touching anything a human wrote. ESC goes too — this text
29//! reaches a terminal, where an escape sequence is not merely a forged line but a forged
30//! *screen*.
31//!
32//! **An escape sequence goes as a whole, not byte by byte.** Before any control character is
33//! filtered, every complete ECMA-48 sequence is taken out with its parameters: a CSI
34//! (`ESC [` or U+009B, parameter and intermediate bytes, one final byte), a control string
35//! (OSC `ESC ]` / U+009D, DCS `ESC P` / U+0090, SOS `ESC X` / U+0098, PM `ESC ^` / U+009E,
36//! APC `ESC _` / U+009F, up to its BEL or ST), and a short `ESC` + final-byte sequence.
37//! Removing only the introducer would leave the rest painted as text — `\x1b[2mdim\x1b[0m`
38//! reading `[2mdim[0m` — so deliberate styling and a peer's attack both disappear cleanly.
39//! [`line_field`] puts a space for every character the sequence occupied, for the same reason
40//! it substitutes a lone control character, so it still never changes a value's character
41//! count; the others remove it.
42//!
43//! A control string is removed only when it is terminated, and its body may hold only
44//! printable ASCII. A dangling `ESC ]` therefore loses just its introducer rather than
45//! swallowing the rest of the line — the text the operator needs to see after a peer's field
46//! (`decision=model_reject`) stays visible. A terminated one is removed whole, which is what a
47//! terminal would have done with it, so what is left is what the terminal would have shown,
48//! without the effect.
49//!
50//! **A validator is not a sanitizer, and several protocols correctly have one instead.** Where
51//! the *model* wrote the string and can be told, LLDP, CDP, HSRP and NATS reject a control
52//! character rather than rewriting it: silently rewriting the answer would make the frame
53//! disagree with the decision the log records. Stripping is for text a *peer* sent, which
54//! cannot be asked to resend. Those sites stay hand-rolled on purpose and are listed, with the
55//! reason, in `tests/control_character_sanitizer_ratchet_test.rs`.
56
57/// Whether `s` holds a control character, for a **validator**: a site that refuses such a
58/// value (the model's answer, or a peer's field it will not echo) instead of rewriting it.
59/// Refusing is right where silently rewriting would make the wire disagree with what was
60/// decided; everywhere a value must be kept, use one of the rewriting helpers below.
61pub fn has_controls(s: &str) -> bool {
62    s.chars().any(char::is_control)
63}
64
65/// Replace every escape sequence and every control character with spaces, one per character,
66/// for a value that occupies one line of a structured record. See the module docs for why this
67/// substitutes rather than deletes.
68pub fn line_field(s: &str) -> String {
69    replace_escape_sequences(s, Some(' '))
70        .chars()
71        .map(|c| if c.is_control() { ' ' } else { c })
72        .collect()
73}
74
75/// Remove every escape sequence and every control character.
76pub fn strip_controls(s: &str) -> String {
77    replace_escape_sequences(s, None)
78        .chars()
79        .filter(|c| !c.is_control())
80        .collect()
81}
82
83/// Remove every escape sequence, and every control character except `\n` and `\t`, for a
84/// genuinely multi-line field.
85///
86/// `\r` is dropped rather than kept: a protocol that wants CRLF endings should normalise them
87/// itself *before* calling this — `s.replace("\r\n", "\n").replace('\r', "\n")` — so a lone CR
88/// in the model's output becomes the line break it meant rather than surviving as a bare
89/// carriage return that overwrites the line a reader has already seen.
90///
91/// `\t` is kept because this variant is for **free text**, where a tab delimits nothing and
92/// cannot forge a record: deleting it is the same column-merging lie [`line_field`] exists to
93/// avoid, and a finger `.plan` is aligned with tabs as a matter of course. A format where the
94/// tab *is* a delimiter — a gopher menu row — is a [`line_field`], not this.
95pub fn multiline(s: &str) -> String {
96    replace_escape_sequences(s, None)
97        .chars()
98        .filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
99        .collect()
100}
101
102/// [`strip_controls`], then truncate to `max_chars` **characters** — never bytes, so this
103/// cannot split a multi-byte character the way `&s[..n]` does (see `crate::utils::truncate`
104/// for the bug that taught us that) — then trim.
105///
106/// The trim comes **after** the cut, and that order is the whole point: cutting first can land
107/// the boundary just after a space, and a value ending in a space is one a second call would
108/// shorten again. `token(token(x, n), n) == token(x, n)` for every `x` and `n`, so "already
109/// sanitised" is a stable predicate — which matters because a value normalised at ingest and
110/// normalised again at render has to compare equal to itself.
111pub fn token(raw: &str, max_chars: usize) -> String {
112    let cleaned = strip_controls(raw);
113    let cut: String = cleaned.trim_start().chars().take(max_chars).collect();
114    cut.trim_end().to_string()
115}
116
117/// Make pretty-printed JSON safe to paint. `serde_json` escapes U+0000..U+001F inside a string
118/// but writes DEL and the C1 range (U+009B is an 8-bit CSI) as raw characters; this writes each
119/// of those as the `\uXXXX` escape it could equally have used. Outside a string the printer
120/// emits no control character but `\n`, so every one this finds is inside a string, the
121/// output is still valid JSON for the same value, and the operator sees which character the
122/// peer sent instead of a deletion.
123pub fn json_text(pretty: &str) -> String {
124    let mut out = String::with_capacity(pretty.len());
125    for c in pretty.chars() {
126        if c.is_control() && c != '\n' {
127            out.push_str(&format!("\\u{:04x}", c as u32));
128        } else {
129            out.push(c);
130        }
131    }
132    out
133}
134
135/// `s` with every character of every complete escape sequence replaced by `with` (or the
136/// sequence removed, for `None`).
137fn replace_escape_sequences(s: &str, with: Option<char>) -> String {
138    if !s.chars().any(is_sequence_introducer) {
139        return s.to_string();
140    }
141    let chars: Vec<char> = s.chars().collect();
142    let mut out = String::with_capacity(s.len());
143    let mut i = 0;
144    while i < chars.len() {
145        match sequence_len(&chars[i..]) {
146            Some(len) => {
147                if let Some(r) = with {
148                    out.extend(std::iter::repeat_n(r, len));
149                }
150                i += len;
151            }
152            None => {
153                out.push(chars[i]);
154                i += 1;
155            }
156        }
157    }
158    out
159}
160
161fn is_sequence_introducer(c: char) -> bool {
162    matches!(
163        c,
164        '\u{1b}' | '\u{9b}' | '\u{9d}' | '\u{90}' | '\u{98}' | '\u{9e}' | '\u{9f}'
165    )
166}
167
168/// Length in characters of the escape sequence starting at `rest[0]`, or `None` when nothing
169/// recognisable starts there (a lone ESC, an ordinary character).
170fn sequence_len(rest: &[char]) -> Option<usize> {
171    enum Kind {
172        Csi,
173        ControlString,
174    }
175    let (kind, body) = match *rest.first()? {
176        '\u{1b}' => match *rest.get(1)? {
177            '[' => (Kind::Csi, 2),
178            ']' | 'P' | 'X' | '^' | '_' => (Kind::ControlString, 2),
179            _ => return short_escape_len(rest),
180        },
181        '\u{9b}' => (Kind::Csi, 1),
182        '\u{9d}' | '\u{90}' | '\u{98}' | '\u{9e}' | '\u{9f}' => (Kind::ControlString, 1),
183        _ => return None,
184    };
185    let in_range = |j: usize, lo: u32, hi: u32| {
186        rest.get(j)
187            .is_some_and(|&c| (lo..=hi).contains(&(c as u32)))
188    };
189    match kind {
190        Kind::Csi => {
191            let mut j = body;
192            while in_range(j, 0x30, 0x3f) {
193                j += 1;
194            }
195            while in_range(j, 0x20, 0x2f) {
196                j += 1;
197            }
198            if in_range(j, 0x40, 0x7e) {
199                Some(j + 1)
200            } else {
201                // Malformed or cut short: drop what was consumed, keep what follows.
202                Some(j)
203            }
204        }
205        Kind::ControlString => {
206            let mut j = body;
207            loop {
208                match rest.get(j).copied() {
209                    Some('\u{7}') | Some('\u{9c}') => return Some(j + 1),
210                    Some('\u{1b}') if rest.get(j + 1) == Some(&'\\') => return Some(j + 2),
211                    Some(c) if (' '..='~').contains(&c) => j += 1,
212                    // Unterminated: only the introducer goes, so a dangling one cannot hide
213                    // the rest of the line.
214                    _ => return Some(body),
215                }
216            }
217        }
218    }
219}
220
221/// `ESC`, any intermediate bytes (0x20..=0x2F), then one final byte (0x30..=0x7E) — a charset
222/// designation, a cursor save, a full reset (`ESC c`).
223fn short_escape_len(rest: &[char]) -> Option<usize> {
224    let mut j = 1;
225    while rest
226        .get(j)
227        .is_some_and(|&c| (0x20..=0x2f).contains(&(c as u32)))
228    {
229        j += 1;
230    }
231    match rest.get(j) {
232        Some(&c) if (0x30..=0x7e).contains(&(c as u32)) => Some(j + 1),
233        _ => None,
234    }
235}