From b30b3c2ff3d313dcc5eba544ca9b8d3b9840f245 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 9 Aug 2026 15:17:31 +0300 Subject: [PATCH] feat(input): preserve kitty key metadata refs #2514 --- src/client/input.rs | 65 +++++- src/client/input/windows_vti.rs | 24 ++- src/input/encode.rs | 49 +++-- src/input/model.rs | 18 +- src/input/parse.rs | 107 ++++++++-- src/input/test_support.rs | 12 +- src/pane/terminal.rs | 8 + src/protocol/wire.rs | 50 ++++- src/raw_input.rs | 190 ++++++++++++++++-- src/server/client_transport.rs | 2 +- .../fixtures/keyboard_encoder_differences.tsv | 2 + tests/fixtures/keyboard_protocol_corpus.tsv | 4 +- 12 files changed, 453 insertions(+), 78 deletions(-) diff --git a/src/client/input.rs b/src/client/input.rs index 456f59b5..e29070b1 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -365,7 +365,7 @@ fn windows_crossterm_reader_loop( Err(_) => break, }; - let raw_sequence_pending = framer.has_pending_input(); + let raw_sequence_pending = framer.requires_raw_continuation(); if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { tracing::debug!( bytes = ?bytes, @@ -521,6 +521,8 @@ fn windows_client_input_event_from_raw( let source = if let Some(bytes) = key.vt_bytes() { crate::protocol::ClientKeySource::Vt { bytes: bytes.to_vec(), + shifted_codepoint: key.shifted_codepoint, + base_layout_codepoint: key.base_layout_codepoint, } } else if let Some(record) = key.windows_record() { crate::protocol::ClientKeySource::WindowsConsole { record } @@ -748,6 +750,25 @@ mod windows_tests { use super::*; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + #[test] + fn windows_oversized_csi_final_is_routed_through_discard_state() { + let mut framer = crate::raw_input::RawInputFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', 4096)); + + assert!(framer.push(&oversized).is_empty()); + assert!(framer.requires_raw_continuation()); + + let final_key = Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty())); + let final_bytes = windows_key_raw_bytes(&final_key, framer.requires_raw_continuation()) + .expect("discard continuation routes through raw framer"); + assert!(framer.push(&final_bytes).is_empty()); + assert!(!framer.requires_raw_continuation()); + + let following_key = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty())); + assert_eq!(windows_key_raw_bytes(&following_key, false), None); + } + #[test] fn windows_control_chars_are_reframed_as_raw_bytes() { let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); @@ -883,7 +904,11 @@ mod windows_tests { kind: crate::protocol::ClientKeyKind::Press, repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![4], + shifted_codepoint: None, + base_layout_codepoint: None, + }, } ); } @@ -907,12 +932,40 @@ mod windows_tests { repeat_count: 1, generated_text: None, source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[A".to_vec() + bytes: b"\x1b[A".to_vec(), + shifted_codepoint: None, + base_layout_codepoint: None, }, } ); } + #[test] + fn windows_raw_kitty_input_preserves_alternate_codepoints() { + let mut framer = crate::raw_input::RawInputFramer::default(); + let mut events = framer.push(b"\x1b[97:65:113;;65:769u"); + assert_eq!(events.len(), 1); + + let event = + windows_client_input_event_from_raw(events.remove(0)).expect("raw key converts"); + let crate::protocol::ClientInputEvent::Key { + generated_text, + source: + crate::protocol::ClientKeySource::Vt { + shifted_codepoint, + base_layout_codepoint, + .. + }, + .. + } = event + else { + panic!("expected VT key event"); + }; + assert_eq!(generated_text.as_deref(), Some("A\u{301}")); + assert_eq!(shifted_codepoint, Some('A' as u32)); + assert_eq!(base_layout_codepoint, Some('q' as u32)); + } + #[test] fn windows_bare_escape_flushes_to_semantic_escape() { let mut framer = crate::raw_input::RawInputFramer::default(); @@ -930,7 +983,11 @@ mod windows_tests { kind: crate::protocol::ClientKeyKind::Press, repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + }, } ); } diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index c95cbc43..15cca590 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -1467,7 +1467,11 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + }, }] ); } @@ -1487,7 +1491,11 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + }, }] ); } @@ -2201,7 +2209,11 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + }, }] ); } @@ -2220,7 +2232,11 @@ mod tests { repeat_count: 1, generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, + source: crate::protocol::ClientKeySource::Vt { + bytes: vec![0x1b], + shifted_codepoint: None, + base_layout_codepoint: None, + }, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, diff --git a/src/input/encode.rs b/src/input/encode.rs index 8ec8a1c5..8c922c5e 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -218,26 +218,27 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option> { _ => {} } - let (codepoint, alternate_shifted) = match key.code { + let (codepoint, alternate_shifted, alternate_base_layout) = match key.code { KeyCode::Char(c) => { let base = canonical_kitty_char(c, mods); let shifted = alternate_shifted_codepoint(key, flags); - (base as u32, shifted) + let base_layout = alternate_base_layout_codepoint(key, flags); + (base as u32, shifted, base_layout) } - KeyCode::Enter => (13, None), - KeyCode::Tab => (9, None), - KeyCode::Backspace => (127, None), - KeyCode::Esc => (27, None), - KeyCode::Left => (57417, None), - KeyCode::Right => (57418, None), - KeyCode::Up => (57419, None), - KeyCode::Down => (57420, None), - KeyCode::PageUp => (57421, None), - KeyCode::PageDown => (57422, None), - KeyCode::Home => (57423, None), - KeyCode::End => (57424, None), - KeyCode::Insert => (57425, None), - KeyCode::Delete => (57426, None), + KeyCode::Enter => (13, None, None), + KeyCode::Tab => (9, None, None), + KeyCode::Backspace => (127, None, None), + KeyCode::Esc => (27, None, None), + KeyCode::Left => (57417, None, None), + KeyCode::Right => (57418, None, None), + KeyCode::Up => (57419, None, None), + KeyCode::Down => (57420, None, None), + KeyCode::PageUp => (57421, None, None), + KeyCode::PageDown => (57422, None, None), + KeyCode::Home => (57423, None, None), + KeyCode::End => (57424, None, None), + KeyCode::Insert => (57425, None, None), + KeyCode::Delete => (57426, None, None), _ => return None, // fall back to legacy for unhandled keys }; @@ -246,8 +247,14 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option> { let mut sequence = String::with_capacity(32); sequence.push_str("\x1b["); write!(&mut sequence, "{codepoint}").ok()?; - if let Some(shifted) = alternate_shifted { - write!(&mut sequence, ":{shifted}").ok()?; + if alternate_shifted.is_some() || alternate_base_layout.is_some() { + sequence.push(':'); + if let Some(shifted) = alternate_shifted { + write!(&mut sequence, "{shifted}").ok()?; + } + if let Some(base_layout) = alternate_base_layout { + write!(&mut sequence, ":{base_layout}").ok()?; + } } write!(&mut sequence, ";{modifier}").ok()?; if let Some(event) = event_suffix { @@ -466,6 +473,12 @@ fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option { } } +fn alternate_base_layout_codepoint(key: &TerminalKey, flags: u16) -> Option { + (flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS != 0) + .then_some(key.base_layout_codepoint) + .flatten() +} + fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option { if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 { return None; diff --git a/src/input/model.rs b/src/input/model.rs index 21252d2d..e2169a0f 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -72,6 +72,7 @@ pub struct TerminalKey { pub kind: crossterm::event::KeyEventKind, pub repeat_count: u16, pub shifted_codepoint: Option, + pub base_layout_codepoint: Option, pub generated_text: Option, source: KeySource, } @@ -84,6 +85,7 @@ impl TerminalKey { kind: crossterm::event::KeyEventKind::Press, repeat_count: 1, shifted_codepoint: None, + base_layout_codepoint: None, generated_text: None, source: KeySource::Synthesized, } @@ -112,12 +114,26 @@ impl TerminalKey { self } - #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { self.shifted_codepoint = Some(shifted_codepoint); self } + pub fn with_base_layout_codepoint(mut self, base_layout_codepoint: u32) -> Self { + self.base_layout_codepoint = Some(base_layout_codepoint); + self + } + + pub(crate) fn with_alternate_codepoints( + mut self, + shifted_codepoint: Option, + base_layout_codepoint: Option, + ) -> Self { + self.shifted_codepoint = shifted_codepoint; + self.base_layout_codepoint = base_layout_codepoint; + self + } + pub(crate) fn with_generated_text(mut self, text: Option) -> Self { self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release { None diff --git a/src/input/parse.rs b/src/input/parse.rs index f1cce49d..9ab776f5 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -2,6 +2,8 @@ use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; use super::TerminalKey; +const MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS: usize = 64; + #[allow(dead_code)] // Next step: raw stdin parser will feed TerminalKey directly through this path. pub fn parse_terminal_key_sequence(data: &str) -> Option { parse_kitty_key_sequence(data) @@ -16,35 +18,38 @@ fn parse_kitty_key_sequence(data: &str) -> Option { let mut fields = body.split(';'); let key_part = fields.next()?; let modifier_part = fields.next().unwrap_or("1"); - let associated_text = fields.next(); + let associated_text = fields.next().filter(|field| !field.is_empty()); if fields.next().is_some() { return None; } + let modifier_part = if modifier_part.is_empty() { + "1" + } else { + modifier_part + }; let (modifier_text, event_type) = split_modifier_and_event(modifier_part); let modifier = modifier_text.parse::().ok()?.checked_sub(1)?; let mut key_fields = key_part.split(':'); let codepoint = key_fields.next()?.parse::().ok()?; - let shifted_codepoint = key_fields - .next() - .filter(|field| !field.is_empty()) - .and_then(|field| field.parse::().ok()); - - if let Some(text) = associated_text { - if text.parse::().ok()? != codepoint { - return None; - } + let shifted_codepoint = parse_optional_kitty_codepoint(key_fields.next())?; + let base_layout_codepoint = parse_optional_kitty_codepoint(key_fields.next())?; + if key_fields.next().is_some() { + return None; } + let generated_text = match associated_text { + Some(text) => Some(parse_kitty_associated_text(text)?), + None => None, + }; let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; let mut modifiers = key_modifiers_from_u8(modifier); // Kitty permits the shifted alternate only while Shift is active. Normalize // contradictory reports here so they cannot dispatch an unshifted command. if matches!(code, KeyCode::Char(_)) - && shifted_codepoint - .is_some_and(|shifted| shifted != codepoint && char::from_u32(shifted).is_some()) + && shifted_codepoint.is_some_and(|shifted| shifted != codepoint) { modifiers |= KeyModifiers::SHIFT; } @@ -53,7 +58,33 @@ fn parse_kitty_key_sequence(data: &str) -> Option { if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(shifted_codepoint); } - Some(key) + if let Some(base_layout_codepoint) = base_layout_codepoint { + key = key.with_base_layout_codepoint(base_layout_codepoint); + } + Some(key.with_generated_text(generated_text)) +} + +fn parse_optional_kitty_codepoint(field: Option<&str>) -> Option> { + let Some(field) = field.filter(|field| !field.is_empty()) else { + return Some(None); + }; + let codepoint = field.parse::().ok()?; + char::from_u32(codepoint).map(|_| Some(codepoint)) +} + +fn parse_kitty_associated_text(text: &str) -> Option { + let mut generated = String::new(); + for (index, field) in text.split(':').enumerate() { + if index >= MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS { + return None; + } + let ch = field.parse::().ok().and_then(char::from_u32)?; + if ch.is_control() { + return None; + } + generated.push(ch); + } + Some(generated) } #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. @@ -662,8 +693,8 @@ mod tests { } #[test] - fn parse_kitty_sequence_with_associated_emoji_text() { - let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap(); + fn parse_kitty_sequence_preserves_associated_text() { + let key = parse_terminal_key_sequence("\x1b[128512;1;128512:65039u").unwrap(); assert_terminal_key_eq( key.clone(), KeyCode::Char('😀'), @@ -671,15 +702,44 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); + assert_eq!(key.generated_text.as_deref(), Some("😀\u{fe0f}")); } #[test] - fn reject_unmodeled_kitty_associated_text() { - assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None); - assert_eq!( - parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"), - None - ); + fn parse_kitty_sequence_preserves_full_alternates_and_omitted_modifiers() { + let key = parse_terminal_key_sequence("\x1b[97:65:113;;65:769u").unwrap(); + + assert_eq!(key.code, KeyCode::Char('a')); + assert_eq!(key.modifiers, KeyModifiers::SHIFT); + assert_eq!(key.shifted_codepoint, Some('A' as u32)); + assert_eq!(key.base_layout_codepoint, Some('q' as u32)); + assert_eq!(key.generated_text.as_deref(), Some("A\u{301}")); + } + + #[test] + fn parse_kitty_sequence_discards_associated_text_on_release() { + let key = parse_terminal_key_sequence("\x1b[97:65;2:3;65u").unwrap(); + + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!(key.generated_text, None); + } + + #[test] + fn reject_malformed_kitty_alternates_and_associated_text() { + for sequence in [ + "\x1b[97:65:113:120;1u", + "\x1b[97;1;1114112u", + "\x1b[97;1;65::66u", + "\x1b[97;1;3u", + "\x1b[97;1;27u", + "\x1b[97;1;133u", + ] { + assert_eq!(parse_terminal_key_sequence(sequence), None, "{sequence:?}"); + } + + let associated = vec!["97"; MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS + 1].join(":"); + let oversized = format!("\x1b[97;1;{associated}u"); + assert_eq!(parse_terminal_key_sequence(&oversized), None); } #[test] @@ -976,6 +1036,11 @@ mod tests { "{} shifted codepoint", case.family ); + assert_eq!( + parsed.base_layout_codepoint, case.base_layout_codepoint, + "{} base layout codepoint", + case.family + ); } } diff --git a/src/input/test_support.rs b/src/input/test_support.rs index 992ac39a..e51466ae 100644 --- a/src/input/test_support.rs +++ b/src/input/test_support.rs @@ -8,6 +8,7 @@ pub(crate) struct KeyboardCorpusCase<'a> { pub modifiers: KeyModifiers, pub kind: KeyEventKind, pub shifted_codepoint: Option, + pub base_layout_codepoint: Option, pub generated_text: Option, pub pane_profile: &'a str, pub expected_pane_hex: &'a str, @@ -23,10 +24,9 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec> } let columns: Vec<_> = line.split('\t').collect(); - assert_eq!( - columns.len(), - 9, - "keyboard corpus row must have 9 columns: {line}" + assert!( + matches!(columns.len(), 9 | 10), + "keyboard corpus row must have 9 or 10 columns: {line}" ); Some(KeyboardCorpusCase { @@ -37,6 +37,10 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec> kind: parse_kind(columns[4]), shifted_codepoint: (!columns[5].is_empty()) .then(|| columns[5].parse::().expect("shifted codepoint")), + base_layout_codepoint: columns + .get(9) + .filter(|field| !field.is_empty()) + .map(|field| field.parse::().expect("base layout codepoint")), generated_text: (columns[6] != "-").then(|| { String::from_utf8(decode_hex(columns[6])).expect("generated text must be UTF-8") }), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 3cc23c49..6ac70d4c 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -3323,6 +3323,11 @@ mod tests { "{} shifted codepoint", case.family ); + assert_eq!( + key.base_layout_codepoint, case.base_layout_codepoint, + "{} base layout codepoint", + case.family + ); assert_eq!( key.generated_text, case.generated_text, "{} generated text", @@ -3350,6 +3355,7 @@ mod tests { "kitty_5" => b"\x1b[>5u".as_slice(), "kitty_7" => b"\x1b[>7u".as_slice(), "kitty_11" => b"\x1b[>11u".as_slice(), + "kitty_13" => b"\x1b[>13u".as_slice(), "kitty_15" => b"\x1b[>15u".as_slice(), "kitty_25" => b"\x1b[>25u".as_slice(), "kitty_31" => b"\x1b[>31u".as_slice(), @@ -3491,6 +3497,8 @@ mod tests { | "kitty_text_policy" | "equivalent_press_suffix" | "missing_modifier" + | "adapter_generated_text_loss" + | "adapter_base_layout_loss" ), "unknown difference category: {}", columns[2] diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 4a445361..6aad49a3 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -140,6 +140,8 @@ pub enum ClientKeySource { Synthesized, Vt { bytes: Vec, + shifted_codepoint: Option, + base_layout_codepoint: Option, }, WindowsConsole { record: crate::input::WindowsKeyRecord, @@ -309,7 +311,13 @@ impl ClientInputEvent { .with_generated_text(generated_text.clone()); key = match source { ClientKeySource::Synthesized => key, - ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()), + ClientKeySource::Vt { + bytes, + shifted_codepoint, + base_layout_codepoint, + } => key + .with_vt_bytes(bytes.clone()) + .with_alternate_codepoints(*shifted_codepoint, *base_layout_codepoint), ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record), }; key = key @@ -1148,13 +1156,15 @@ mod tests { source: crate::protocol::ClientKeySource::Synthesized, }, ClientInputEvent::Key { - code: ClientKeyCode::Backspace, - modifiers: 0, + code: ClientKeyCode::Char('l'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, repeat_count: 3, - generated_text: None, + generated_text: Some("L".to_owned()), source: crate::protocol::ClientKeySource::Vt { - bytes: b"\x1b[127;1u".to_vec(), + bytes: b"\x1b[108:76:113;2;76u".to_vec(), + shifted_codepoint: Some('L' as u32), + base_layout_codepoint: Some('q' as u32), }, }, ClientInputEvent::Key { @@ -1188,9 +1198,10 @@ mod tests { assert_eq!( encoded, vec![ - 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49, - 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, - 130, 2, 0, 0, 3, 4, 0, + 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 15, 108, 1, 0, 3, 1, 1, 76, 1, 18, 27, 91, 49, + 48, 56, 58, 55, 54, 58, 49, 49, 51, 59, 50, 59, 55, 54, 117, 1, 76, 1, 113, 0, 14, + 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, 130, 2, 0, + 0, 3, 4, 0, ] ); let (decoded, _): (ClientMessage, _) = @@ -1219,6 +1230,29 @@ mod tests { } } + #[test] + fn vt_client_input_preserves_alternate_codepoints() { + let event = ClientInputEvent::Key { + code: ClientKeyCode::Char('a'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("A\u{301}".to_owned()), + source: ClientKeySource::Vt { + bytes: b"\x1b[97:65:113;;65:769u".to_vec(), + shifted_codepoint: Some('A' as u32), + base_layout_codepoint: Some('q' as u32), + }, + }; + + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("expected key event"); + }; + assert_eq!(key.shifted_codepoint, Some('A' as u32)); + assert_eq!(key.base_layout_codepoint, Some('q' as u32)); + assert_eq!(key.generated_text.as_deref(), Some("A\u{301}")); + } + #[test] fn client_input_events_convert_to_raw_keys() { let record = crate::input::WindowsKeyRecord { diff --git a/src/raw_input.rs b/src/raw_input.rs index 0d524ebc..31853094 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -181,6 +181,11 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } + #[cfg(any(windows, test))] + pub(crate) fn requires_raw_continuation(&self) -> bool { + self.byte_framer.has_pending_input() || self.byte_framer.discard_until.is_some() + } + pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { self.byte_framer.has_pending_incomplete_mouse_sequence() } @@ -232,6 +237,9 @@ const HOST_COLOR_QUERY_REPLIES: u16 = 258; #[cfg(any(unix, test))] const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1; const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32; +const MAX_CSI_SEQUENCE_BYTES: usize = 4096; +const MAX_LEGACY_ESCAPE_PREFIXES: usize = 64; +const CSI_FINAL_BYTES: &[u8] = b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; impl RawInputByteFramer { pub(crate) fn for_host_input() -> Self { @@ -313,7 +321,10 @@ impl RawInputByteFramer { let mut chunks = self.drain_available_chunks(); if let Some(family) = self.discard_until { - if family == ControlStringFamily::HostReplyCsi { + if matches!( + family, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi + ) { return chunks; } if family == ControlStringFamily::OrphanedSgrMouseTail { @@ -408,7 +419,7 @@ impl RawInputByteFramer { ); self.host_cell_size_replies_awaited = 0; self.held_pending_host_reply_esc = false; - self.discard_until = Some(ControlStringFamily::HostReplyCsi); + self.discard_until = Some(ControlStringFamily::CsiTail); self.discarded_tail_bytes = 0; self.buffer.clear(); return chunks; @@ -429,7 +440,7 @@ impl RawInputByteFramer { ); self.host_appearance_reply_awaited = false; self.held_pending_host_reply_esc = false; - self.discard_until = Some(ControlStringFamily::HostReplyCsi); + self.discard_until = Some(ControlStringFamily::CsiTail); self.discarded_tail_bytes = 0; self.buffer.clear(); return chunks; @@ -496,6 +507,19 @@ impl RawInputByteFramer { let mut chunks = Vec::new(); loop { + let escape_count = self.buffer.iter().take_while(|byte| **byte == ESC).count(); + if self.discard_until.is_none() && escape_count > MAX_LEGACY_ESCAPE_PREFIXES { + let excess = escape_count - MAX_LEGACY_ESCAPE_PREFIXES; + tracing::warn!(excess, "splitting excessive legacy escape prefixes"); + chunks.extend((0..excess).map(|_| vec![ESC])); + self.buffer.drain(..excess); + continue; + } + + if self.discard_oversized_csi_prefix() { + continue; + } + if self.lone_escape_recently_flushed { if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) { break; @@ -508,15 +532,21 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { - if family == ControlStringFamily::HostReplyCsi { - if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) - { + if family == ControlStringFamily::CsiTail { + if discard_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) { self.discard_until = None; self.discarded_tail_bytes = 0; continue; } break; } + if family == ControlStringFamily::OversizedCsi { + if discard_oversized_csi_tail(&mut self.buffer) { + self.discard_until = None; + continue; + } + break; + } if family == ControlStringFamily::OrphanedSgrMouseTail { if discard_orphaned_sgr_mouse_tail( &mut self.buffer, @@ -574,6 +604,36 @@ impl RawInputByteFramer { chunks } + + fn discard_oversized_csi_prefix(&mut self) -> bool { + if self.discard_until.is_some() { + return false; + } + let Some(offset) = csi_sequence_offset(&self.buffer) else { + return false; + }; + let csi = &self.buffer[offset..]; + + match find_csi_final(csi, CSI_FINAL_BYTES) { + Some(len) if len > MAX_CSI_SEQUENCE_BYTES => { + let consumed = offset + len; + tracing::warn!(len = consumed, "discarding oversized CSI input sequence"); + self.buffer.drain(..consumed); + true + } + None if csi.len() > MAX_CSI_SEQUENCE_BYTES => { + tracing::warn!( + len = self.buffer.len(), + "discarding oversized incomplete CSI input sequence" + ); + self.buffer.clear(); + self.discard_until = Some(ControlStringFamily::OversizedCsi); + self.discarded_tail_bytes = 0; + true + } + Some(_) | None => false, + } + } } const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128; @@ -602,7 +662,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> ) }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), - ControlStringFamily::HostReplyCsi => false, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => false, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), @@ -901,7 +961,8 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { enum ControlStringFamily { Osc, StTerminated, - HostReplyCsi, + CsiTail, + OversizedCsi, OrphanedSgrMouseTail, } @@ -1022,6 +1083,11 @@ fn utf8_char_width(first: u8) -> Option { } } +fn csi_sequence_offset(buffer: &[u8]) -> Option { + let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count(); + (escape_count > 0 && buffer.get(escape_count) == Some(&b'[')).then(|| escape_count - 1) +} + fn complete_escape_sequence_len(buffer: &[u8]) -> Option { if buffer.len() == 1 { return None; @@ -1043,8 +1109,18 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option { return Some(1); } - if buffer.starts_with(b"\x1b\x1b") { - return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1); + let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count(); + if escape_count > MAX_LEGACY_ESCAPE_PREFIXES { + return Some(1); + } + let escape_offset = escape_count.saturating_sub(1); + let sequence = &buffer[escape_offset..]; + complete_single_escape_sequence_len(sequence).map(|len| escape_offset + len) +} + +fn complete_single_escape_sequence_len(buffer: &[u8]) -> Option { + if buffer.len() == 1 { + return None; } if buffer.starts_with(b"\x1b[") { @@ -1054,10 +1130,7 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option { if buffer.starts_with(b"\x1b[M") { return (buffer.len() >= 6).then_some(6); } - return find_csi_final( - buffer, - b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", - ); + return find_csi_final(buffer, CSI_FINAL_BYTES); } if let Some(control) = control_string(buffer) { @@ -1136,7 +1209,22 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail( } } -fn discard_host_reply_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { +fn discard_oversized_csi_tail(buffer: &mut Vec) -> bool { + for (index, byte) in buffer.iter().copied().enumerate() { + match byte { + 0x20..=0x3f => {} + _ => { + buffer.drain(..=index); + return true; + } + } + } + + buffer.clear(); + false +} + +fn discard_csi_tail(buffer: &mut Vec, discarded_tail_bytes: &mut usize) -> bool { let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); let inspected = buffer.len().min(remaining); @@ -1213,7 +1301,7 @@ fn control_string_terminator_for_family( match family { ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), - ControlStringFamily::HostReplyCsi => None, + ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => None, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .position(|byte| matches!(*byte, b'M' | b'm')) @@ -1945,6 +2033,11 @@ mod tests { "{} shifted codepoint", case.family ); + assert_eq!( + key.base_layout_codepoint, case.base_layout_codepoint, + "{} base layout codepoint", + case.family + ); assert_eq!( key.generated_text, case.generated_text, "{} generated text", @@ -2311,6 +2404,71 @@ mod tests { ); } + #[test] + fn oversized_csi_input_is_discarded_without_losing_following_keys() { + let mut incomplete = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(incomplete.push(&oversized).is_empty()); + assert!(!incomplete.has_pending_input()); + assert!(incomplete + .push(&[b'2'; MAX_DISCARDED_CONTROL_TAIL_BYTES + 1]) + .is_empty()); + assert!(!incomplete.has_pending_input()); + assert!(incomplete.push(b"u").is_empty()); + assert_eq!(incomplete.push(b"x"), vec![b"x".to_vec()]); + + let mut complete = RawInputByteFramer::default(); + oversized.extend_from_slice(b"uy"); + assert_eq!(complete.push(&oversized), vec![b"y".to_vec()]); + assert!(!complete.has_pending_input()); + } + + #[test] + fn oversized_csi_discard_precedes_excess_escape_batching() { + let mut framer = RawInputByteFramer::default(); + let mut oversized = b"\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + assert!(framer.push(&oversized).is_empty()); + + let mut continuation = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES + 1]; + continuation.push(b'x'); + let rebuilt = framer + .push(&continuation) + .into_iter() + .flatten() + .collect::>(); + + assert_eq!(rebuilt, continuation[1..]); + assert!(!framer.has_pending_input()); + assert!(framer.discard_until.is_none()); + } + + #[test] + fn doubled_escape_cannot_bypass_oversized_csi_limit() { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut oversized = b"\x1b\x1b[".to_vec(); + oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES)); + + assert!(framer.push(&oversized).is_empty()); + assert!(!framer.has_pending_input()); + assert!(framer.push(b"u").is_empty()); + assert_eq!(framer.push(b"x"), vec![b"x".to_vec()]); + } + + #[test] + fn repeated_escape_prefixes_are_drained_iteratively() { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut input = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES * 1024]; + input.extend_from_slice(b"[A"); + + let chunks = framer.push(&input); + let rebuilt = chunks.into_iter().flatten().collect::>(); + + assert_eq!(rebuilt, input); + assert!(!framer.has_pending_input()); + } + #[test] fn chunked_bracketed_paste_waits_for_terminator() { let (tx, mut rx) = mpsc::channel(8); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index b5023b97..e3304a5d 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -451,7 +451,7 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { .saturating_mul(usize::from((*repeat_count).max(1))), ); } - if let crate::protocol::ClientKeySource::Vt { bytes } = source { + if let crate::protocol::ClientKeySource::Vt { bytes, .. } = source { input_bytes = input_bytes.saturating_add(bytes.len()); } } diff --git a/tests/fixtures/keyboard_encoder_differences.tsv b/tests/fixtures/keyboard_encoder_differences.tsv index c147717d..ee102736 100644 --- a/tests/fixtures/keyboard_encoder_differences.tsv +++ b/tests/fixtures/keyboard_encoder_differences.tsv @@ -17,3 +17,5 @@ ghostty kitty_shift_letter_kitty_31 equivalent_press_suffix 1b5b3130383a37363b32 ghostty kitty_super_a_kitty_31 equivalent_press_suffix 1b5b39373b3975 ghostty kitty_hyper_a_kitty_31 missing_modifier 1b5b39373b3b393775 ghostty kitty_meta_a_kitty_31 missing_modifier 1b5b39373b3b393775 +ghostty kitty_full_alternates_associated_text adapter_generated_text_loss 41 +ghostty kitty_full_alternates_kitty_13 adapter_base_layout_loss 1b5b39373a36353b3275 diff --git a/tests/fixtures/keyboard_protocol_corpus.tsv b/tests/fixtures/keyboard_protocol_corpus.tsv index fc089c6b..e808589d 100644 --- a/tests/fixtures/keyboard_protocol_corpus.tsv +++ b/tests/fixtures/keyboard_protocol_corpus.tsv @@ -1,4 +1,4 @@ -# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex +# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex base_layout_codepoint(optional) legacy_ctrl_space 00 char: control press - legacy 00 legacy_ctrl_a 01 char:a control press - legacy 01 legacy_ctrl_b 02 char:b control press - legacy 02 @@ -120,3 +120,5 @@ kitty_repeat_a_kitty_11 1b5b39373b313a3275 char:a - repeat - kitty_11 1b5b39373 kitty_super_a_kitty_31 1b5b39373b3975 char:a super press - kitty_31 1b5b39373b393a3175 kitty_hyper_a_kitty_31 1b5b39373b313775 char:a hyper press - kitty_31 1b5b39373b31373a3175 kitty_meta_a_kitty_31 1b5b39373b333375 char:a meta press - kitty_31 1b5b39373b33333a3175 +kitty_full_alternates_kitty_13 1b5b39373a36353a3131333b3275 char:a shift press 65 - kitty_13 1b5b39373a36353a3131333b3275 113 +kitty_full_alternates_associated_text 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 legacy 41cc81 113