pub fn sanitise(field: &str) -> (String, bool) {
if !field.chars().any(is_unprintable) {
return (field.to_owned(), false);
}
let stripped: String = field
.chars()
.filter_map(|ch| {
if !is_unprintable(ch) {
Some(ch)
} else if is_line_or_space_like(ch) {
Some(' ')
} else {
None
}
})
.collect();
let cleaned = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
(cleaned, true)
}
fn is_unprintable(ch: char) -> bool {
ch.is_control()
|| matches!(ch,
'\u{ad}' | '\u{34f}' | '\u{61c}' | '\u{115f}' | '\u{1160}' | '\u{180b}'..='\u{180f}' | '\u{200b}'..='\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{206f}' | '\u{3164}' | '\u{fe00}'..='\u{fe0f}' | '\u{feff}' | '\u{ffa0}' | '\u{fff9}'..='\u{fffb}' | '\u{1d173}'..='\u{1d17a}' | '\u{e0000}'..='\u{e007f}' | '\u{e0100}'..='\u{e01ef}' )
}
fn is_line_or_space_like(ch: char) -> bool {
matches!(
ch,
'\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_escape_class_is_stripped() {
let hostile = [
("\u{1b}[2J", "clears the screen"),
("\u{1b}]0;pwned\u{7}", "rewrites the window title"),
("\u{1b}[31mred", "forges shep's own colour"),
("before\rafter", "overwrites the line with a bare CR"),
("a\u{0}b", "a nul byte"),
("tab\there", "a raw tab"),
("line\nbreak", "escapes the row"),
];
for (input, why) in hostile {
let (clean, changed) = sanitise(input);
assert!(changed, "{why}: should have been reported as sanitised");
assert!(
!clean.contains('\u{1b}'),
"{why}: escape survived in {clean:?}"
);
for ch in clean.chars() {
assert!(
!ch.is_control(),
"{why}: control char survived in {clean:?}"
);
}
}
}
#[test]
fn ordinary_text_is_left_exactly_alone() {
let (clean, changed) = sanitise("Rotates grown log files. MIT OR Apache-2.0.");
assert_eq!(clean, "Rotates grown log files. MIT OR Apache-2.0.");
assert!(!changed);
}
#[test]
fn non_ascii_prose_survives_because_it_is_not_the_threat() {
let (clean, changed) = sanitise("rotiert Protokolldateien");
assert_eq!(clean, "rotiert Protokolldateien");
assert!(!changed);
}
#[test]
fn a_lone_escape_at_the_end_of_a_string_is_stripped() {
let (clean, changed) = sanitise("tail\u{1b}");
assert_eq!(clean, "tail");
assert!(changed);
}
#[test]
fn the_single_character_csi_introducer_is_stripped() {
let (clean, changed) = sanitise("clean\u{9b}2Jhere");
assert!(changed);
assert!(!clean.contains('\u{9b}'), "C1 CSI survived in {clean:?}");
}
#[test]
fn invisible_and_reordering_characters_are_stripped() {
let hostile = [
('\u{202e}', "right-to-left override"),
('\u{202d}', "left-to-right override"),
('\u{2066}', "left-to-right isolate"),
('\u{200d}', "zero width joiner"),
('\u{200b}', "zero width space"),
('\u{2060}', "word joiner"),
('\u{feff}', "byte order mark"),
('\u{2028}', "line separator"),
];
for (ch, why) in hostile {
let (clean, changed) = sanitise(&format!("safe{ch}text"));
assert!(changed, "{why}: should have been reported as sanitised");
assert!(!clean.contains(ch), "{why}: survived in {clean:?}");
}
}
#[test]
fn a_stripped_line_break_leaves_a_space_behind() {
let (clean, changed) = sanitise("line\nbreak");
assert_eq!(clean, "line break");
assert!(changed);
}
#[test]
fn the_invisible_classes_are_taken_whole_not_as_a_remembered_subset() {
let hostile = [
('\u{ad}', "soft hyphen"),
('\u{34f}', "combining grapheme joiner"),
('\u{61c}', "arabic letter mark, a bidi control"),
('\u{115f}', "hangul choseong filler, renders blank"),
('\u{1160}', "hangul jungseong filler, renders blank"),
('\u{180e}', "mongolian vowel separator"),
('\u{206b}', "deprecated: activate symmetric swapping"),
('\u{3164}', "hangul filler, renders blank"),
('\u{fe0f}', "variation selector 16"),
('\u{ffa0}', "halfwidth hangul filler, renders blank"),
('\u{1d173}', "musical symbol begin beam"),
('\u{e0041}', "tag latin capital A: an invisible letter"),
('\u{e0101}', "variation selector supplement"),
];
for (ch, why) in hostile {
let (clean, changed) = sanitise(&format!("safe{ch}text"));
assert!(changed, "{why}: should have been reported as sanitised");
assert!(!clean.contains(ch), "{why}: survived in {clean:?}");
}
}
#[test]
fn a_hidden_ascii_string_written_in_tags_does_not_survive() {
let hidden: String = "rm -rf"
.chars()
.map(|c| char::from_u32(0xe_0000 + c as u32).unwrap())
.collect();
let (clean, changed) = sanitise(&format!("Spot{hidden}"));
assert!(changed);
assert_eq!(clean, "Spot");
}
#[test]
fn prose_in_other_scripts_still_survives_the_wider_list() {
for ordinary in [
"\u{30ed}\u{30b0}\u{3092}\u{30ed}\u{30fc}\u{30c6}\u{30fc}\u{30c8}", "\u{65e5}\u{8a8c}\u{306e}\u{56de}\u{8ee2}", "rotiert Protokolldateien",
"cafe\u{301}", "\u{2764}", ] {
let (clean, changed) = sanitise(ordinary);
assert_eq!(clean, ordinary, "prose was altered");
assert!(!changed, "prose was reported as sanitised");
}
}
}