/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/util/console.rs
712 строк
20 KB
Nathan Whitaker
fix(cli): escape control characters in external metadata (#36198)
06 авг 2026, 00:25
Не верифицирован
06 авг 2026, 00:25
fdda8b7
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::borrow::Cow; use std::io; use std::sync::Arc; use console_static_text::ConsoleStaticText; use crossterm::ExecutableCommand; use crossterm::cursor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use crossterm::terminal; use deno_core::parking_lot::Mutex; use deno_runtime::ops::tty::ConsoleSize; use unicode_general_category::GeneralCategory; use unicode_general_category::get_general_category; use super::draw_thread::DrawThread; /// Gets the console size. pub fn console_size() -> Option<ConsoleSize> { deno_runtime::ops::tty::console_size_of_stderr().ok() } pub fn new_console_static_text() -> ConsoleStaticText { ConsoleStaticText::new(move || { let size = console_size(); let to_u16 = |value: u32| value.min(u16::MAX as u32) as u16; console_static_text::ConsoleSize { cols: size.map(|size| size.cols).map(to_u16), rows: size.map(|size| size.rows).map(to_u16), } }) } /// Escapes terminal control and other non-printable characters in text that /// will be embedded in Deno-owned terminal output. /// /// Unlike [`filter_destructive_ansi`], this does not preserve styling from the /// input. Control characters are rendered visibly so surrounding text remains /// understandable. pub fn escape_terminal_control_chars(text: &str) -> Cow<'_, str> { if !text.contains(is_terminal_unprintable) { return Cow::Borrowed(text); } let mut output = String::with_capacity(text.len()); for c in text.chars() { if is_terminal_unprintable(c) { output.extend(c.escape_debug()); } else { output.push(c); } } Cow::Owned(output) } fn is_terminal_unprintable(c: char) -> bool { matches!( get_general_category(c), GeneralCategory::Control | GeneralCategory::Format | GeneralCategory::Surrogate | GeneralCategory::PrivateUse | GeneralCategory::Unassigned | GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator ) } /// Strips destructive ANSI escape sequences from user output while preserving /// SGR (color/style) sequences. Returns `Cow::Borrowed` when no filtering needed. pub fn filter_destructive_ansi(input: &[u8]) -> Cow<'_, [u8]> { if !input .iter() .any(|&b| b == 0x1b || b == 0x07 || b == 0x08 || b == b'\r') { return Cow::Borrowed(input); } let mut out = Vec::with_capacity(input.len()); let mut i = 0; while i < input.len() { match input[i] { 0x07 | 0x08 => i += 1, // Strip standalone \r (line-overwrite), keep \r\n b'\r' if i + 1 < input.len() && input[i + 1] == b'\n' => { out.extend_from_slice(b"\r\n"); i += 2; } b'\r' => i += 1, 0x1b if i + 1 >= input.len() => i += 1, 0x1b => { match input[i + 1] { b'[' => { let seq_end = skip_csi(&input[i..]); // Keep SGR sequences (final byte 'm', no private marker '?'/'>'/'<') let final_byte = input.get(i + seq_end - 1); let has_private = input .get(i + 2) .is_some_and(|&b| matches!(b, b'?' | b'>' | b'<')); if final_byte == Some(&b'm') && !has_private { out.extend_from_slice(&input[i..i + seq_end]); } i += seq_end; } // OSC/DCS/PM/APC: string sequences terminated by BEL/ST b']' | b'P' | b'^' | b'_' => i += skip_str_seq(&input[i..]), // Two-byte ESC sequences (Fe/Fp/Fs) 0x30..=0x7E => i += 2, // nF: ESC + intermediate bytes (0x20..=0x2F) + final byte 0x20..=0x2F => { i += 2; while i < input.len() && (0x20..=0x2F).contains(&input[i]) { i += 1; } if i < input.len() && (0x30..=0x7E).contains(&input[i]) { i += 1; } } _ => i += 1, } } b => { out.push(b); i += 1; } } } Cow::Owned(out) } /// Returns the length of a CSI sequence (`ESC [` params final-byte). fn skip_csi(data: &[u8]) -> usize { let mut j = 2; if j < data.len() && matches!(data[j], b'?' | b'>' | b'<') { j += 1; } while j < data.len() && (0x30..=0x3F).contains(&data[j]) { j += 1; } while j < data.len() && (0x20..=0x2F).contains(&data[j]) { j += 1; } if j < data.len() && (0x40..=0x7E).contains(&data[j]) { j += 1; } j } /// Skips an OSC/DCS/PM/APC string sequence terminated by BEL, ST (ESC \), or 0x9c. fn skip_str_seq(data: &[u8]) -> usize { let mut j = 2; while j < data.len() { match data[j] { 0x07 | 0x9c => return j + 1, 0x1b if data.get(j + 1) == Some(&b'\\') => return j + 2, _ => j += 1, } } j } pub struct RawMode { needs_disable: bool, #[cfg(windows)] original_mode: Option<u32>, } impl RawMode { pub fn enable() -> io::Result<Self> { terminal::enable_raw_mode()?; #[cfg(windows)] { // Clear ENABLE_VIRTUAL_TERMINAL_INPUT so that arrow keys and // special keys are delivered as VK_* key events via // ReadConsoleInput, rather than as VT escape sequences. // Windows Terminal enables this flag by default, which causes // crossterm to miss arrow keys, Enter, and Ctrl+C. let original_mode = windows_vt_input::clear_vt_input_flag(); Ok(Self { needs_disable: true, original_mode, }) } #[cfg(not(windows))] Ok(Self { needs_disable: true, }) } pub fn disable(mut self) -> io::Result<()> { self.needs_disable = false; #[cfg(windows)] windows_vt_input::restore_mode(self.original_mode); terminal::disable_raw_mode() } } impl Drop for RawMode { fn drop(&mut self) { if self.needs_disable { #[cfg(windows)] windows_vt_input::restore_mode(self.original_mode); let _ = terminal::disable_raw_mode(); } } } #[cfg(windows)] mod windows_vt_input { use windows_sys::Win32::System::Console::GetConsoleMode; use windows_sys::Win32::System::Console::GetStdHandle; use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; use windows_sys::Win32::System::Console::SetConsoleMode; const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; /// Clear ENABLE_VIRTUAL_TERMINAL_INPUT on stdin and return the /// original mode so it can be restored later. pub fn clear_vt_input_flag() -> Option<u32> { // SAFETY: GetStdHandle/GetConsoleMode/SetConsoleMode are safe // Windows API calls with valid handle constants. unsafe { let handle = GetStdHandle(STD_INPUT_HANDLE); if handle.is_null() { return None; } let mut mode: u32 = 0; if GetConsoleMode(handle, &mut mode) == 0 { return None; } if mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0 { SetConsoleMode(handle, mode & !ENABLE_VIRTUAL_TERMINAL_INPUT); Some(mode) } else { None // flag wasn't set, nothing to restore } } } pub fn restore_mode(original_mode: Option<u32>) { if let Some(mode) = original_mode { // SAFETY: restoring previously saved console mode. unsafe { let handle = GetStdHandle(STD_INPUT_HANDLE); if !handle.is_null() { SetConsoleMode(handle, mode); } } } } } /// A snapshot of the terminal mode that can be restored later. /// /// This is used by `deno task` so that a child process (for example a dev /// server like `vite`) that switches the terminal into raw mode and is then /// terminated (e.g. via Ctrl+C) does not leave the user's terminal in a broken /// state with input echo and line editing disabled. /// /// On non-Windows platforms this is a no-op: there the child process belongs to /// the same process group and the controlling terminal's line discipline is /// restored by the shell, so there's nothing for `deno` to do. #[derive(Clone, Copy, Default)] pub struct SavedTerminalMode { #[cfg(windows)] stdin_mode: Option<u32>, #[cfg(windows)] stdout_mode: Option<u32>, } impl SavedTerminalMode { /// Captures the current terminal mode so it can be restored later. pub fn capture() -> Self { #[cfg(windows)] { Self { stdin_mode: windows_console_mode::get( windows_sys::Win32::System::Console::STD_INPUT_HANDLE, ), stdout_mode: windows_console_mode::get( windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE, ), } } #[cfg(not(windows))] { Self {} } } /// Restores the captured terminal mode. Safe to call multiple times. pub fn restore(&self) { #[cfg(windows)] { windows_console_mode::set( windows_sys::Win32::System::Console::STD_INPUT_HANDLE, self.stdin_mode, ); windows_console_mode::set( windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE, self.stdout_mode, ); } } } /// Captures the terminal mode on creation and restores it on drop, acting as a /// backstop so the terminal is always returned to its original state once a /// task and all of its children have finished. See [`SavedTerminalMode`]. pub struct TerminalModeGuard(SavedTerminalMode); impl TerminalModeGuard { pub fn acquire() -> Self { Self(SavedTerminalMode::capture()) } /// Returns a copy of the captured mode so it can also be restored eagerly /// (for example as soon as Ctrl+C is received). pub fn saved(&self) -> SavedTerminalMode { self.0 } } #[cfg(windows)] impl Drop for TerminalModeGuard { fn drop(&mut self) { self.0.restore(); } } #[cfg(windows)] mod windows_console_mode { use windows_sys::Win32::System::Console::GetConsoleMode; use windows_sys::Win32::System::Console::GetStdHandle; use windows_sys::Win32::System::Console::STD_HANDLE; use windows_sys::Win32::System::Console::SetConsoleMode; /// Reads the console mode for the given std handle. Returns `None` when the /// handle is not a console (e.g. redirected to a pipe or file), in which case /// there is no mode to restore. pub fn get(std_handle: STD_HANDLE) -> Option<u32> { // SAFETY: GetStdHandle/GetConsoleMode are safe Windows API calls with // valid handle constants. unsafe { let handle = GetStdHandle(std_handle); if handle.is_null() { return None; } let mut mode: u32 = 0; if GetConsoleMode(handle, &mut mode) == 0 { return None; } Some(mode) } } pub fn set(std_handle: STD_HANDLE, mode: Option<u32>) { let Some(mode) = mode else { return; }; // SAFETY: restoring a previously saved console mode. unsafe { let handle = GetStdHandle(std_handle); if !handle.is_null() { SetConsoleMode(handle, mode); } } } } pub struct HideCursorGuard { needs_disable: bool, } impl HideCursorGuard { pub fn hide() -> io::Result<Self> { io::stderr().execute(cursor::Hide)?; Ok(Self { needs_disable: true, }) } pub fn show(mut self) -> io::Result<()> { self.needs_disable = false; io::stderr().execute(cursor::Show)?; Ok(()) } } impl Drop for HideCursorGuard { fn drop(&mut self) { if self.needs_disable { _ = io::stderr().execute(cursor::Show); } } } #[derive(Debug)] pub struct ConfirmOptions { pub message: String, pub default: bool, } /// Prompts and confirms if a tty. /// /// Returns `None` when a tty. pub fn confirm(options: ConfirmOptions) -> Option<bool> { #[derive(Debug)] struct PromptRenderer { options: ConfirmOptions, selection: Arc<Mutex<String>>, } impl super::draw_thread::DrawThreadRenderer for PromptRenderer { fn render(&self, _data: &ConsoleSize) -> String { let is_yes_default = self.options.default; let selection = self.selection.lock(); format!( "{} [{}/{}] {}", self.options.message, if is_yes_default { "Y" } else { "y" }, if is_yes_default { "n" } else { "N" }, *selection, ) } } if !DrawThread::is_supported() { return None; } let _raw_mode = RawMode::enable().ok()?; let _hide_cursor_guard = HideCursorGuard::hide().ok()?; let selection = Arc::new(Mutex::new(String::new())); let default = options.default; // uses a renderer and the draw thread in order to allow // displaying other stuff on the draw thread while the prompt // is showing let renderer = PromptRenderer { options, selection: selection.clone(), }; let _state = DrawThread::add_entry(Arc::new(renderer)); let mut selected = default; loop { let event = crossterm::event::read().ok()?; #[allow(clippy::single_match, reason = "more extendable")] match event { crossterm::event::Event::Key(KeyEvent { kind: KeyEventKind::Press, code, modifiers, .. }) => match (code, modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Char('q'), KeyModifiers::NONE) => break, (KeyCode::Char('y'), KeyModifiers::NONE | KeyModifiers::SHIFT) => { selected = true; *selection.lock() = "Y".to_string(); } (KeyCode::Char('n'), KeyModifiers::NONE | KeyModifiers::SHIFT) => { selected = false; *selection.lock() = "N".to_string(); } (KeyCode::Backspace, _) => { selected = default; *selection.lock() = "".to_string(); } // l is common for enter in vim keybindings (KeyCode::Enter, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => { return Some(selected); } _ => {} }, _ => {} } } None } #[cfg(test)] mod tests { use super::*; #[test] fn terminal_mode_guard_acquire_and_drop() { // Should not panic whether or not a console is attached (in CI stdin is // typically redirected, so the captured mode is `None` and restoring is a // no-op). On a real console it captures and restores the mode. The guard // is restored when it goes out of scope at the end of this block, and the // captured snapshot can be restored eagerly any number of times. { let guard = TerminalModeGuard::acquire(); let saved = guard.saved(); saved.restore(); saved.restore(); } SavedTerminalMode::capture().restore(); } #[test] fn escape_terminal_control_chars_preserves_printable_unicode() { let input = "hello 文件 😀"; let result = escape_terminal_control_chars(input); assert!(matches!(result, Cow::Borrowed(_))); assert_eq!(result, input); } #[test] fn escape_terminal_control_chars_makes_controls_visible() { let input = "before\x1b[2J\x07\t\n\u{009b}31mafter"; let result = escape_terminal_control_chars(input); assert_eq!(result, r"before\u{1b}[2J\u{7}\t\n\u{9b}31mafter"); } #[test] fn escape_terminal_control_chars_makes_invisible_chars_visible() { let controls = [ '\u{061c}', '\u{200e}', '\u{200f}', '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{2028}', '\u{2029}', '\u{feff}', '\u{200b}', '\u{e0000}', '\u{e007f}', '\u{e000}', '\u{0378}', ]; for control in controls { let input = format!("before{control}after"); let result = escape_terminal_control_chars(&input); assert!(!result.contains(control)); assert!(result.contains(&format!(r"\u{{{:x}}}", control as u32))); } } #[test] fn filter_destructive_ansi_plain_text() { let input = b"hello world"; let result = filter_destructive_ansi(input); assert!(matches!(result, Cow::Borrowed(_))); assert_eq!(&*result, b"hello world"); } #[test] fn filter_destructive_ansi_preserves_sgr_color() { let input = b"\x1b[31mred\x1b[0m"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"\x1b[31mred\x1b[0m"); } #[test] fn filter_destructive_ansi_preserves_complex_sgr() { let input = b"\x1b[1;38;2;255;0;0mbold red\x1b[0m"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"\x1b[1;38;2;255;0;0mbold red\x1b[0m"); } #[test] fn filter_destructive_ansi_strips_clear_screen() { let input = b"before\x1b[2Jafter"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"beforeafter"); } #[test] fn filter_destructive_ansi_strips_cursor_up() { let input = b"line\x1b[2Aup"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"lineup"); } #[test] fn filter_destructive_ansi_strips_erase_line() { let input = b"text\x1b[Kmore"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"textmore"); } #[test] fn filter_destructive_ansi_strips_cursor_position() { let input = b"start\x1b[10;20Hend"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"startend"); } #[test] fn filter_destructive_ansi_strips_terminal_reset() { let input = b"before\x1bcafter"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"beforeafter"); } #[test] fn filter_destructive_ansi_strips_private_mode() { // Hide cursor let input = b"text\x1b[?25lmore"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"textmore"); } #[test] fn filter_destructive_ansi_strips_osc_title() { let input = b"before\x1b]0;evil title\x07after"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"beforeafter"); } #[test] fn filter_destructive_ansi_strips_osc_with_st() { let input = b"before\x1b]0;title\x1b\\after"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"beforeafter"); } #[test] fn filter_destructive_ansi_mixed_sequences() { // SGR red + clear screen + text + SGR reset let input = b"\x1b[31mred\x1b[2Jtext\x1b[0m"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"\x1b[31mredtext\x1b[0m"); } #[test] fn filter_destructive_ansi_bel_and_bs_stripped() { let input = b"hello\x07world\x08!"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"helloworld!"); } #[test] fn filter_destructive_ansi_preserves_whitespace() { let input = b"line1\nline2\ttab"; let result = filter_destructive_ansi(input); assert!(matches!(result, Cow::Borrowed(_))); assert_eq!(&*result, b"line1\nline2\ttab"); } #[test] fn filter_destructive_ansi_strips_standalone_cr() { // Standalone \r (used by progress bars to overwrite lines) is stripped let input = b"progress\roverwrite"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"progressoverwrite"); } #[test] fn filter_destructive_ansi_preserves_crlf() { // \r\n line endings are preserved let input = b"line1\r\nline2\r\n"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"line1\r\nline2\r\n"); } #[test] fn filter_destructive_ansi_strips_alt_screen() { // Alt screen on and off let input = b"\x1b[?1049hcontent\x1b[?1049l"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"content"); } #[test] fn filter_destructive_ansi_strips_dcs_sequence() { let input = b"before\x1bPsome data\x1b\\after"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"beforeafter"); } #[test] fn filter_destructive_ansi_strips_scroll_up() { let input = b"text\x1b[3Smore"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"textmore"); } #[test] fn filter_destructive_ansi_trailing_esc() { let input = b"text\x1b"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"text"); } #[test] fn filter_destructive_ansi_sgr_reset_bare() { // Bare ESC[m is equivalent to ESC[0m let input = b"\x1b[mtext"; let result = filter_destructive_ansi(input); assert_eq!(&*result, b"\x1b[mtext"); } }