/
githubmirror
/
fd
Обзор
Документация
Войти
/
githubmirror
/
fd
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/sanitize.rs
168 строк
5 KB
curious-rabbit
cleanup
05 май 2026, 22:48
05 май 2026, 22:48
c96398c
Код
Авторство
О чём код?
//! TTY-output sanitization to prevent terminal escape injection via filenames. use std::borrow::Cow; use std::fmt::Write; /// True for any char that is neither printable nor permitted whitespace (only HT). /// Covers C0/C1/DEL, bidi overrides, zero-width and format chars, and tag chars. #[inline] fn needs_escape(c: char) -> bool { if c == '\t' { return false; } c.is_control() || matches!(c, '\u{00AD}' // soft hyphen (invisible) | '\u{180E}' // Mongolian vowel separator | '\u{200B}'..='\u{200F}' // zero-width + LRM/RLM | '\u{202A}'..='\u{202E}' // bidi embedding/override | '\u{2060}'..='\u{206F}' // word joiner, invisibles, deprecated formats | '\u{FEFF}' // BOM / zero-width no-break space | '\u{FFF9}'..='\u{FFFB}' // interlinear annotation | '\u{E0000}'..='\u{E007F}' // language tags ) } /// Returns a `Cow<str>` borrowing `s` when no escaping is needed, otherwise an owned /// escaped copy. Use this when an owned `&str`/`String` is required (e.g. ANSI paint). pub fn sanitize_for_terminal(s: &str) -> Cow<'_, str> { if !s.chars().any(needs_escape) { return Cow::Borrowed(s); } let mut out = String::with_capacity(s.len()); for c in s.chars() { if needs_escape(c) { let v = c as u32; if v <= 0xFF { let _ = write!(out, "\\x{v:02X}"); } else { let _ = write!(out, "\\u{{{v:04X}}}"); } } else { out.push(c); } } Cow::Owned(out) } /// Sanitize for terminal output only; raw bytes pass through on pipes/files. pub fn maybe_sanitize<'a>(s: &'a str, is_terminal: bool) -> Cow<'a, str> { if is_terminal { sanitize_for_terminal(s) } else { Cow::Borrowed(s) } } #[cfg(test)] mod tests { use super::*; #[test] fn preserves_safe_content() { for s in [ "hello.txt", "résumé.pdf", "文档.txt", "🦀.rs", "a\tb", "a\u{FFFD}b", ] { assert!( matches!(sanitize_for_terminal(s), Cow::Borrowed(_)), "{s:?}" ); assert_eq!(sanitize_for_terminal(s), s); } } #[test] fn strips_osc52_clipboard_payload() { let attack = "innocent\x1b]52;c;cHduZWQ=\x1b\\.txt"; let safe = sanitize_for_terminal(attack); assert!(!safe.contains('\x1b')); assert_eq!(safe, "innocent\\x1B]52;c;cHduZWQ=\\x1B\\.txt"); } #[test] fn strips_cr_output_forgery() { assert_eq!(sanitize_for_terminal("A\rFAKE OUTPUT"), "A\\x0DFAKE OUTPUT"); } #[test] fn strips_osc8_hyperlink_injection() { let attack = "phish\x1b]8;;https:evil.example\x1b\\phony.txt"; assert!(!sanitize_for_terminal(attack).contains('\x1b')); } #[test] fn strips_del() { assert_eq!(sanitize_for_terminal("a\x7fb"), "a\\x7Fb"); } #[test] fn strips_bel_and_null() { assert_eq!(sanitize_for_terminal("a\x07b"), "a\\x07b"); assert_eq!(sanitize_for_terminal("a\0b"), "a\\x00b"); } #[test] fn strips_newline() { assert_eq!(sanitize_for_terminal("a\nb"), "a\\x0Ab"); } #[test] fn escape_preserves_information() { let s = "name\x1bX\x07Y.txt"; assert_eq!(sanitize_for_terminal(s), "name\\x1BX\\x07Y.txt"); } #[test] fn strips_c1_csi_and_osc_initiators() { // U+009B is CSI, U+009D is OSC; dangerous on 8-bit-control terminals. assert_eq!(sanitize_for_terminal("\u{9b}31m"), "\\x9B31m"); assert_eq!( sanitize_for_terminal("\u{9d}0;pwned\u{9c}"), "\\x9D0;pwned\\x9C" ); } #[test] fn strips_bidi_overrides_and_zero_width() { // Trojan-Source style RLO/LRO that flip rendered order of filename text. assert_eq!( sanitize_for_terminal("safe\u{202E}fil\u{202D}gnp.exe"), "safe\\u{202E}fil\\u{202D}gnp.exe" ); // Zero-width space and BOM are also format chars used to disguise filenames. assert_eq!(sanitize_for_terminal("a\u{200B}b"), "a\\u{200B}b"); assert_eq!(sanitize_for_terminal("\u{FEFF}name"), "\\u{FEFF}name"); } #[test] fn keeps_legitimate_unicode_features() { // Variation selectors (U+FE0F, U+E0100..) modify preceding glyphs in CJK/emoji // and are legitimate in filenames. Private-use chars are used by icon fonts. for s in [ "heart\u{2764}\u{FE0F}.txt", "icon\u{E000}.cfg", "cjk\u{6F22}\u{E0101}.txt", ] { assert!( matches!(sanitize_for_terminal(s), Cow::Borrowed(_)), "{s:?}" ); } } #[test] fn maybe_sanitize_passthrough_when_not_terminal() { let attack = "x\x1by"; // Pipe context: bytes pass through unchanged (zero-copy). let out = maybe_sanitize(attack, false); assert!(matches!(out, Cow::Borrowed(_))); assert_eq!(out, attack); // TTY context: escapes apply. assert_eq!(maybe_sanitize(attack, true), "x\\x1By"); } }