Skip to main content

Module sanitize

Module sanitize 

Source
Expand description

Control-character hygiene for text the model supplies.

Six protocols hand-rolled a version of this before it lived here — gopher’s sanitize_field, finger’s strip_controls and strip_controls_multiline, ident’s sanitize_token, whois’s sanitize_line_field, and the finger client’s own copy. They were written by copying a neighbour, which is also how whois — the one the others were told to copy — ended up without one at all for a long time.

The four variants below are not interchangeable, and the difference is the whole point. Picking the wrong one is a silent correctness bug, not a style choice:

  • line_field maps a control character to a space. Use it for a field that occupies one line of a structured record — a Key: value line, a tab-delimited menu row. Deleting the character instead would concatenate the two sides into one word (Good RegistrarRegistrant), which reads as a single legitimate value and is its own small lie. The guarantee is no forged line, not that the text is unchanged.
  • strip_controls removes them. Use it where the surrounding format has no columns to shift, so a deletion cannot be mistaken for content.
  • multiline removes them except newline and tab. Use it only for a field whose whole purpose is to be free multi-line text (finger’s plan and project).
  • token is strip_controls, then trim, then a character bound — for a short identifier that also has a length the format cannot exceed.

json_text is not a fifth variant of the same thing: it is for pretty-printed JSON shown as-is, and escapes rather than removes, so the text stays JSON for the same value.

All four keep non-ASCII text intact: char::is_control is Unicode-aware, so this strips C0, DEL and the C1 range without touching anything a human wrote. ESC goes too — this text reaches a terminal, where an escape sequence is not merely a forged line but a forged screen.

An escape sequence goes as a whole, not byte by byte. Before any control character is filtered, every complete ECMA-48 sequence is taken out with its parameters: a CSI (ESC [ or U+009B, parameter and intermediate bytes, one final byte), a control string (OSC ESC ] / U+009D, DCS ESC P / U+0090, SOS ESC X / U+0098, PM ESC ^ / U+009E, APC ESC _ / U+009F, up to its BEL or ST), and a short ESC + final-byte sequence. Removing only the introducer would leave the rest painted as text — \x1b[2mdim\x1b[0m reading [2mdim[0m — so deliberate styling and a peer’s attack both disappear cleanly. line_field puts a space for every character the sequence occupied, for the same reason it substitutes a lone control character, so it still never changes a value’s character count; the others remove it.

A control string is removed only when it is terminated, and its body may hold only printable ASCII. A dangling ESC ] therefore loses just its introducer rather than swallowing the rest of the line — the text the operator needs to see after a peer’s field (decision=model_reject) stays visible. A terminated one is removed whole, which is what a terminal would have done with it, so what is left is what the terminal would have shown, without the effect.

A validator is not a sanitizer, and several protocols correctly have one instead. Where the model wrote the string and can be told, LLDP, CDP, HSRP and NATS reject a control character rather than rewriting it: silently rewriting the answer would make the frame disagree with the decision the log records. Stripping is for text a peer sent, which cannot be asked to resend. Those sites stay hand-rolled on purpose and are listed, with the reason, in tests/control_character_sanitizer_ratchet_test.rs.

Functions§

has_controls
Whether s holds a control character, for a validator: a site that refuses such a value (the model’s answer, or a peer’s field it will not echo) instead of rewriting it. Refusing is right where silently rewriting would make the wire disagree with what was decided; everywhere a value must be kept, use one of the rewriting helpers below.
json_text
Make pretty-printed JSON safe to paint. serde_json escapes U+0000..U+001F inside a string but writes DEL and the C1 range (U+009B is an 8-bit CSI) as raw characters; this writes each of those as the \uXXXX escape it could equally have used. Outside a string the printer emits no control character but \n, so every one this finds is inside a string, the output is still valid JSON for the same value, and the operator sees which character the peer sent instead of a deletion.
line_field
Replace every escape sequence and every control character with spaces, one per character, for a value that occupies one line of a structured record. See the module docs for why this substitutes rather than deletes.
multiline
Remove every escape sequence, and every control character except \n and \t, for a genuinely multi-line field.
strip_controls
Remove every escape sequence and every control character.
token
strip_controls, then truncate to max_chars characters — never bytes, so this cannot split a multi-byte character the way &s[..n] does (see crate::utils::truncate for the bug that taught us that) — then trim.