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_fieldmaps a control character to a space. Use it for a field that occupies one line of a structured record — aKey: valueline, 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_controlsremoves them. Use it where the surrounding format has no columns to shift, so a deletion cannot be mistaken for content.multilineremoves them except newline and tab. Use it only for a field whose whole purpose is to be free multi-line text (finger’splanandproject).tokenisstrip_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
sholds 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_jsonescapes 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\uXXXXescape 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
\nand\t, for a genuinely multi-line field. - strip_
controls - Remove every escape sequence and every control character.
- token
strip_controls, then truncate tomax_charscharacters — never bytes, so this cannot split a multi-byte character the way&s[..n]does (seecrate::utils::truncatefor the bug that taught us that) — then trim.