pub fn sanitize_terminal_output(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
match b {
0x1B => {
i = skip_escape_sequence(bytes, i);
}
b'\n' | b'\t' => {
out.push(b as char);
i += 1;
}
0x00..=0x1F | 0x7F => {
i += 1;
}
0x20..=0x7E => {
out.push(b as char);
i += 1;
}
_ => {
let ch_start = i;
i += 1;
while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
i += 1;
}
let chunk = &bytes[ch_start..i];
if is_utf8_c1(chunk) {
} else {
match std::str::from_utf8(chunk) {
Ok(s) => out.push_str(s),
Err(_) => out.push('\u{FFFD}'),
}
}
}
}
}
out
}
fn skip_escape_sequence(bytes: &[u8], start: usize) -> usize {
if start + 1 >= bytes.len() {
return start + 1;
}
match bytes[start + 1] {
b'[' => {
let mut i = start + 2;
while i < bytes.len() && (0x30..=0x3F).contains(&bytes[i]) {
i += 1;
}
while i < bytes.len() && (0x20..=0x2F).contains(&bytes[i]) {
i += 1;
}
if i < bytes.len() && (0x40..=0x7E).contains(&bytes[i]) {
i += 1;
}
i
}
b']' => {
let mut i = start + 2;
let cap = i.saturating_add(512);
while i < bytes.len() && i < cap {
if bytes[i] == 0x07 {
i += 1;
return i;
}
if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
return i + 2;
}
i += 1;
}
i
}
_ => start + 2,
}
}
fn is_utf8_c1(chunk: &[u8]) -> bool {
chunk.len() == 2 && chunk[0] == 0xC2 && (0x80..=0x9F).contains(&chunk[1])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_csi_color_and_bel_and_backspace() {
assert_eq!(sanitize_terminal_output("\x1b[31m\x07inject\x08"), "inject");
}
#[test]
fn preserves_ascii_printable_and_newlines_and_tabs() {
let s = "hello\tworld\nend";
assert_eq!(sanitize_terminal_output(s), s);
}
#[test]
fn strips_carriage_return_used_to_overwrite_previous_line() {
assert_eq!(sanitize_terminal_output("SAFE\rEVIL"), "SAFEEVIL");
}
#[test]
fn strips_osc_set_window_title_bel_terminated() {
assert_eq!(
sanitize_terminal_output("\x1b]0;attacker owned\x07visible"),
"visible"
);
}
#[test]
fn strips_osc_st_terminated() {
assert_eq!(sanitize_terminal_output("\x1b]2;title\x1b\\rest"), "rest");
}
#[test]
fn drops_unterminated_osc_up_to_cap() {
let mut s = String::from("\x1b]");
s.push_str(&"A".repeat(1024));
let out = sanitize_terminal_output(&s);
assert!(
out.len() < 700,
"unterminated OSC must be capped, got {} bytes",
out.len()
);
}
#[test]
fn strips_arbitrary_c0_but_keeps_newline_and_tab() {
let s = "a\x00b\x01c\x1fd\x7fe\ttab\nnl";
assert_eq!(sanitize_terminal_output(s), "abcde\ttab\nnl");
}
#[test]
fn strips_unicode_c1_controls() {
let s = "before\u{0085}\u{0090}after";
assert_eq!(sanitize_terminal_output(s), "beforeafter");
}
#[test]
fn preserves_multibyte_utf8() {
let s = "привет · café · 日本語";
assert_eq!(sanitize_terminal_output(s), s);
}
#[test]
fn strips_bare_esc_at_end_of_input() {
assert_eq!(sanitize_terminal_output("data\x1b"), "data");
}
#[test]
fn strips_two_byte_escape_sequences() {
assert_eq!(sanitize_terminal_output("a\x1b7b\x1b=c"), "abc");
}
#[test]
fn empty_input_returns_empty() {
assert_eq!(sanitize_terminal_output(""), "");
}
#[test]
fn nested_and_mixed_sequences() {
let s = "user\x1b[31m\x1b]0;pwned\x07\x00cn=alice\nend";
assert_eq!(sanitize_terminal_output(s), "usercn=alice\nend");
}
#[test]
fn csi_with_parameters_intermediates_and_final() {
assert_eq!(sanitize_terminal_output("a\x1b[1;2 qb"), "ab");
}
#[test]
fn del_stripped() {
assert_eq!(sanitize_terminal_output("safe\x7fdel"), "safedel");
}
}