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}