pub const MAX_UNTRUSTED_FIELD_CHARS: usize = 4 * 1024;
pub fn sanitize_untrusted_field(value: &str) -> String {
value
.chars()
.filter_map(|ch| match ch {
'\n' => Some('\n'),
'\t' | '\r' => Some(' '),
_ if ch.is_control() || is_bidi_control(ch) => None,
_ => Some(ch),
})
.take(MAX_UNTRUSTED_FIELD_CHARS)
.collect()
}
pub fn sanitize_untrusted_line(value: &str) -> String {
sanitize_untrusted_field(value).replace('\n', " ")
}
pub fn sanitize_untrusted_path(path: &std::path::Path) -> String {
sanitize_untrusted_line(&path.to_string_lossy())
}
fn is_bidi_control(ch: char) -> bool {
matches!(
ch,
'\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_terminal_sequences_but_keeps_text_and_newlines() {
let input = "before\x1b]52;c;Y2xpcGJvYXJk\x07after\nnext\tcolumn\rreturn\u{202e}spoof";
assert_eq!(
sanitize_untrusted_field(input),
"before]52;c;Y2xpcGJvYXJkafter\nnext column returnspoof"
);
}
#[test]
fn collapses_newlines_so_untrusted_text_cannot_forge_a_line() {
let stderr = "tar: \x1b[2Kall good\nRESTORED: 0 files";
let out = sanitize_untrusted_line(stderr);
assert!(!out.contains('\n'), "{out:?}");
assert!(!out.contains('\u{1b}'), "{out:?}");
assert_eq!(out, "tar: [2Kall good RESTORED: 0 files");
}
#[test]
fn a_path_carrying_an_escape_renders_without_it() {
let path = std::path::Path::new("/tmp/\x1b[2Kspoofed");
assert_eq!(sanitize_untrusted_path(path), "/tmp/[2Kspoofed");
}
#[test]
fn caps_untrusted_fields_without_splitting_unicode() {
let input = "é".repeat(MAX_UNTRUSTED_FIELD_CHARS + 10);
let output = sanitize_untrusted_field(&input);
assert_eq!(output.chars().count(), MAX_UNTRUSTED_FIELD_CHARS);
assert!(output.chars().all(|ch| ch == 'é'));
}
}