Fix: Remove terminal queries from history to prevent terminal responses on stdin (#10)

This commit is contained in:
David Beesley
2026-01-20 12:54:43 +13:00
committed by GitHub
parent 68c04b5d2c
commit 35ecc80e2f
4 changed files with 529 additions and 6 deletions

View File

@@ -0,0 +1,504 @@
/// Filters out terminal query escape sequences that would cause the terminal
/// to respond via stdin when replayed.
///
/// Query sequences include:
/// - CSI c, CSI 0c, CSI >c, CSI >0c, CSI =c (Device Attributes)
/// - CSI 5n, CSI 6n, CSI ?6n (Device Status / Cursor Position Reports)
/// - OSC N;? ST (color/property queries)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum FilterState {
#[default]
Normal,
Escape, // Saw ESC
Csi, // Saw ESC [
CsiParam, // Saw ESC [ followed by params
CsiGt, // Saw ESC [ >
CsiGtParam, // Saw ESC [ > followed by params
CsiEq, // Saw ESC [ =
CsiQuestion, // Saw ESC [ ?
CsiQuestionParam, // Saw ESC [ ? followed by params
Osc, // Saw ESC ]
OscParam, // Inside OSC, collecting param number
OscSemicolon, // Saw ; in OSC
OscQuery, // Saw ? after ; in OSC (query sequence)
OscQuerySt, // Saw ESC in OSC query, looking for \
}
/// Stateful filter for terminal query sequences.
/// Maintains state across multiple filter() calls to handle sequences
/// that are split across chunk boundaries.
#[derive(Debug, Default)]
pub struct TerminalQueryFilter {
state: FilterState,
pending: Vec<u8>,
}
impl TerminalQueryFilter {
pub fn new() -> Self {
Self {
state: FilterState::Normal,
pending: Vec::with_capacity(32),
}
}
/// Filter terminal query sequences from input bytes.
/// Returns filtered output. Maintains state for sequences split across calls.
pub fn filter(&mut self, input: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(input.len());
for &byte in input {
match self.state {
FilterState::Normal => {
if byte == 0x1B {
self.state = FilterState::Escape;
self.pending.clear();
self.pending.push(byte);
} else {
output.push(byte);
}
}
FilterState::Escape => {
self.pending.push(byte);
match byte {
b'[' => self.state = FilterState::Csi,
b']' => self.state = FilterState::Osc,
_ => {
// Not a sequence we care about, emit pending
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::Csi => {
self.pending.push(byte);
match byte {
b'>' => self.state = FilterState::CsiGt,
b'=' => self.state = FilterState::CsiEq,
b'?' => self.state = FilterState::CsiQuestion,
b'c' => {
// ESC [ c - Primary DA query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
b'0'..=b'9' => self.state = FilterState::CsiParam,
_ => {
// Unknown CSI sequence, emit
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiParam => {
self.pending.push(byte);
match byte {
b'0'..=b'9' | b';' => {} // Continue collecting params
b'c' => {
// ESC [ 0 c or similar - Primary DA query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
b'n' => {
// Check if this is 5n or 6n (Device Status Reports)
if is_device_status_query(&self.pending) {
// Discard query
self.pending.clear();
} else {
output.extend_from_slice(&self.pending);
self.pending.clear();
}
self.state = FilterState::Normal;
}
_ => {
// End of CSI sequence, emit
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiGt => {
self.pending.push(byte);
match byte {
b'c' => {
// ESC [ > c - Secondary DA query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
b'0'..=b'9' => self.state = FilterState::CsiGtParam,
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiGtParam => {
self.pending.push(byte);
match byte {
b'0'..=b'9' => {} // Continue
b'c' => {
// ESC [ > 0 c - Secondary DA query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiEq => {
self.pending.push(byte);
match byte {
b'c' => {
// ESC [ = c - Tertiary DA query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiQuestion => {
self.pending.push(byte);
match byte {
b'0'..=b'9' => self.state = FilterState::CsiQuestionParam,
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::CsiQuestionParam => {
self.pending.push(byte);
match byte {
b'0'..=b'9' | b';' => {} // Continue
b'n' => {
// ESC [ ? N n - Extended cursor position query, discard
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::Osc => {
self.pending.push(byte);
match byte {
b'0'..=b'9' => self.state = FilterState::OscParam,
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::OscParam => {
self.pending.push(byte);
match byte {
b'0'..=b'9' => {} // Continue
b';' => self.state = FilterState::OscSemicolon,
0x07 | 0x1B => {
// End of OSC (BEL or ST), not a query
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::OscSemicolon => {
self.pending.push(byte);
match byte {
b'?' => self.state = FilterState::OscQuery,
0x07 => {
// Not a query, emit
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
0x1B => {
// Start of ST, not a query
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
// Some other content, not a query
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::OscQuery => {
self.pending.push(byte);
match byte {
0x07 => {
// ESC ] N ; ? BEL - Query sequence, discard
self.pending.clear();
self.state = FilterState::Normal;
}
0x1B => self.state = FilterState::OscQuerySt,
_ => {
// Not a simple query, might be content starting with ?
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
FilterState::OscQuerySt => {
self.pending.push(byte);
match byte {
b'\\' => {
// ESC ] N ; ? ESC \ - Query sequence, discard
self.pending.clear();
self.state = FilterState::Normal;
}
_ => {
// Not ST, emit what we have
output.extend_from_slice(&self.pending);
self.pending.clear();
self.state = FilterState::Normal;
}
}
}
}
}
// Don't emit pending bytes - keep them for next call
// They'll be emitted when the sequence completes
output
}
/// Flush any pending bytes. Call this when done filtering to get
/// any incomplete sequences that should be emitted.
pub fn flush(&mut self) -> Vec<u8> {
let result = std::mem::take(&mut self.pending);
self.state = FilterState::Normal;
result
}
}
/// Check if pending buffer is a device status query (5n or 6n)
fn is_device_status_query(pending: &[u8]) -> bool {
// pending should be like [ESC, '[', '5', 'n'] or [ESC, '[', '6', 'n']
if pending.len() < 4 {
return false;
}
// Check for patterns like ESC [ 5 n or ESC [ 6 n
// The number part could be just '5' or '6'
let param_start = 2;
let param_end = pending.len() - 1; // exclude the 'n'
let param_slice = &pending[param_start..param_end];
// Parse the parameter
if let Ok(param_str) = std::str::from_utf8(param_slice)
&& let Ok(param) = param_str.parse::<u32>()
{
return param == 5 || param == 6;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_filter_normal_text() {
let mut filter = TerminalQueryFilter::new();
let input = b"Hello, World!";
let output = filter.filter(input);
assert_eq!(output, input.to_vec());
}
#[test]
fn test_no_filter_normal_escape_sequences() {
let mut filter = TerminalQueryFilter::new();
// Color codes should pass through
let input = b"\x1b[31mRed\x1b[0m";
let output = filter.filter(input);
assert_eq!(output, input.to_vec());
}
#[test]
fn test_filter_primary_da_query() {
let mut filter = TerminalQueryFilter::new();
// ESC [ c
let input = b"before\x1b[cafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_primary_da_query_with_param() {
let mut filter = TerminalQueryFilter::new();
// ESC [ 0 c
let input = b"before\x1b[0cafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_secondary_da_query() {
let mut filter = TerminalQueryFilter::new();
// ESC [ > c
let input = b"before\x1b[>cafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_secondary_da_query_with_param() {
let mut filter = TerminalQueryFilter::new();
// ESC [ > 0 c
let input = b"before\x1b[>0cafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_tertiary_da_query() {
let mut filter = TerminalQueryFilter::new();
// ESC [ = c
let input = b"before\x1b[=cafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_device_status_5n() {
let mut filter = TerminalQueryFilter::new();
// ESC [ 5 n
let input = b"before\x1b[5nafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_device_status_6n() {
let mut filter = TerminalQueryFilter::new();
// ESC [ 6 n
let input = b"before\x1b[6nafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_extended_cursor_position() {
let mut filter = TerminalQueryFilter::new();
// ESC [ ? 6 n
let input = b"before\x1b[?6nafter";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_osc_query_with_bel() {
let mut filter = TerminalQueryFilter::new();
// ESC ] 11 ; ? BEL
let input = b"before\x1b]11;?\x07after";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_filter_osc_query_with_st() {
let mut filter = TerminalQueryFilter::new();
// ESC ] 11 ; ? ESC \
let input = b"before\x1b]11;?\x1b\\after";
let output = filter.filter(input);
assert_eq!(output, b"beforeafter".to_vec());
}
#[test]
fn test_no_filter_osc_set() {
let mut filter = TerminalQueryFilter::new();
// ESC ] 11 ; color BEL - setting color, not query
let input = b"before\x1b]11;rgb:00/00/00\x07after";
let output = filter.filter(input);
// This should pass through since it's not a query (doesn't start with ?)
assert_eq!(output, input.to_vec());
}
#[test]
fn test_filter_multiple_queries() {
let mut filter = TerminalQueryFilter::new();
let input = b"start\x1b[c\x1b]11;?\x07\x1b[6nend";
let output = filter.filter(input);
assert_eq!(output, b"startend".to_vec());
}
#[test]
fn test_preserve_cursor_movement() {
let mut filter = TerminalQueryFilter::new();
// Cursor movement should not be filtered
let input = b"\x1b[H\x1b[2J";
let output = filter.filter(input);
assert_eq!(output, input.to_vec());
}
#[test]
fn test_split_sequence_query_filtered() {
let mut filter = TerminalQueryFilter::new();
// Split ESC [ c across two calls - should still be filtered
let output1 = filter.filter(b"before\x1b[");
let output2 = filter.filter(b"cafter");
assert_eq!(output1, b"before".to_vec());
assert_eq!(output2, b"after".to_vec());
}
#[test]
fn test_split_sequence_non_query_preserved() {
let mut filter = TerminalQueryFilter::new();
// Split ESC [ 3 1 m across two calls - should be preserved
let output1 = filter.filter(b"before\x1b[31");
let output2 = filter.filter(b"mafter");
assert_eq!(output1, b"before".to_vec());
assert_eq!(output2, b"\x1b[31mafter".to_vec());
}
#[test]
fn test_flush_incomplete_sequence() {
let mut filter = TerminalQueryFilter::new();
// Incomplete sequence at end
let output = filter.filter(b"text\x1b[");
assert_eq!(output, b"text".to_vec());
// Flush should emit the incomplete sequence
let flushed = filter.flush();
assert_eq!(flushed, b"\x1b[".to_vec());
}
}

View File

@@ -1,4 +1,5 @@
pub mod config;
pub mod escape_filter;
pub mod escape_sequences;
pub mod key_parser;
pub mod line_buffer;

View File

@@ -1,3 +1,4 @@
use crate::escape_filter::TerminalQueryFilter;
use crate::escape_sequences::{
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
@@ -94,6 +95,7 @@ pub struct Proxy {
child: Child,
original_termios: Option<Termios>,
history: LineBuffer,
history_filter: TerminalQueryFilter,
vt_parser: vt100::Parser,
vt_prev_screen: Option<vt100::Screen>,
last_output_time: Option<Instant>,
@@ -171,6 +173,7 @@ impl Proxy {
Ok(Self {
history,
history_filter: TerminalQueryFilter::new(),
config,
pty_master: pty.master,
child,
@@ -299,7 +302,7 @@ impl Proxy {
// Still feed VT and history while in alt screen so they stay in sync
if feed_vt {
self.vt_parser.process(data);
self.history.push_bytes(data);
self.push_to_history(data);
}
return self.process_output_alt_screen(data, stdout_fd);
}
@@ -334,7 +337,7 @@ impl Proxy {
self.flush_sync_block_to_history();
self.in_sync_block = false;
} else {
self.history.push_bytes(remaining);
self.push_to_history(remaining);
}
self.in_alternate_screen = true;
let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]);
@@ -359,7 +362,7 @@ impl Proxy {
debug!("process_output: SYNC_START at pos={}", pos + idx);
// Add any data before SYNC_START to history
if idx > 0 {
self.history.push_bytes(&data[pos..pos + idx]);
self.push_to_history(&data[pos..pos + idx]);
}
self.in_sync_block = true;
self.sync_buffer.clear();
@@ -367,7 +370,7 @@ impl Proxy {
pos += idx + SYNC_START.len();
} else {
// No sync block, just add to history
self.history.push_bytes(&data[pos..]);
self.push_to_history(&data[pos..]);
break;
}
}
@@ -477,10 +480,17 @@ impl Proxy {
self.history.push_bytes(CLEAR_SCREEN);
self.history.push_bytes(CURSOR_HOME);
}
self.history.push_bytes(&self.sync_buffer);
self.push_to_history(&self.sync_buffer.clone());
self.sync_buffer.clear();
}
/// Push data to history, filtering out terminal query sequences that would
/// cause the terminal to respond when replayed.
fn push_to_history(&mut self, data: &[u8]) {
let filtered = self.history_filter.filter(data);
self.history.push_bytes(&filtered);
}
fn flush_pending_vt_render<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen {
return Ok(());

View File

@@ -206,7 +206,15 @@ if {$vi_cmd eq ""} {
# Start vi/vim with no config to avoid user rc issues
send "$vi_cmd -u NONE\r"
sleep 1
# Wait for alt screen sequence before sending input
# This ensures vim has fully started and alt screen is active
# Note: \033 is octal for ESC, \[ escapes the bracket in TCL
expect {
-re "\033\\\[\\?1049h" { }
timeout { fail "$vi_cmd did not enter alt screen" }
}
sleep 0.3
# In vi, Ctrl+^ switches between buffers - should NOT trigger lookback
send "\x1e"