//! Wire protocol for herdr server/client communication. //! //! Defines the message types, framing, version negotiation, and safety //! constraints for the binary protocol over Unix domain sockets. use std::collections::HashMap; use std::io::{self, Read, Write}; use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- // Protocol constants // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. pub const PROTOCOL_VERSION: u32 = 14; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; /// Maximum allowed server-to-client frame payload when Kitty graphics are enabled. /// Normal traffic keeps `MAX_FRAME_SIZE`; this larger cap is only for explicit /// image payloads that are naturally much larger after base64 encoding. pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024; /// Maximum clipboard image payload size for remote paste bridging. pub const MAX_CLIPBOARD_IMAGE_PAYLOAD: usize = 16 * 1024 * 1024; /// Length of the u32 little-endian length prefix in bytes. const LENGTH_PREFIX_BYTES: usize = 4; // --------------------------------------------------------------------------- // Client → Server messages // --------------------------------------------------------------------------- /// Render payload encoding negotiated during client handshake. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RenderEncoding { /// Send full semantic FrameData values. This is the local/default mode. SemanticFrame, /// Send already-diffed terminal ANSI byte streams. TerminalAnsi, } /// Keybinding profile requested by an attached app client. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientKeybindings { /// Use the server's own keybinding config. Server, /// Use this attached client's normalized local `[keys]` config. Local { keys_toml: String }, } /// Client behavior requested at connection time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientLaunchMode { /// Full app client. App, /// Direct terminal attach client. TerminalAttach, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientKeyKind { Press, Repeat, Release, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientKeyCode { Backspace, Enter, Left, Right, Up, Down, Home, End, PageUp, PageDown, Tab, BackTab, Delete, Insert, Esc, Char(char), F(u8), Null, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMouseButton { Left, Right, Middle, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMouseKind { Down(ClientMouseButton), Up(ClientMouseButton), Drag(ClientMouseButton), Moved, ScrollUp, ScrollDown, ScrollLeft, ScrollRight, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientInputEvent { Key { code: ClientKeyCode, modifiers: u8, kind: ClientKeyKind, }, Mouse { kind: ClientMouseKind, column: u16, row: u16, modifiers: u8, }, Paste { text: String, }, FocusGained, FocusLost, } impl ClientKeyKind { #[cfg(any(windows, test))] pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self { match kind { crossterm::event::KeyEventKind::Press => Self::Press, crossterm::event::KeyEventKind::Repeat => Self::Repeat, crossterm::event::KeyEventKind::Release => Self::Release, } } pub(crate) fn to_crossterm(self) -> crossterm::event::KeyEventKind { match self { Self::Press => crossterm::event::KeyEventKind::Press, Self::Repeat => crossterm::event::KeyEventKind::Repeat, Self::Release => crossterm::event::KeyEventKind::Release, } } } impl ClientKeyCode { #[cfg(any(windows, test))] pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option { use crossterm::event::KeyCode; Some(match code { KeyCode::Backspace => Self::Backspace, KeyCode::Enter => Self::Enter, KeyCode::Left => Self::Left, KeyCode::Right => Self::Right, KeyCode::Up => Self::Up, KeyCode::Down => Self::Down, KeyCode::Home => Self::Home, KeyCode::End => Self::End, KeyCode::PageUp => Self::PageUp, KeyCode::PageDown => Self::PageDown, KeyCode::Tab => Self::Tab, KeyCode::BackTab => Self::BackTab, KeyCode::Delete => Self::Delete, KeyCode::Insert => Self::Insert, KeyCode::Esc => Self::Esc, KeyCode::Char(ch) => Self::Char(ch), KeyCode::F(n) => Self::F(n), KeyCode::Null => Self::Null, _ => return None, }) } pub(crate) fn to_crossterm(&self) -> crossterm::event::KeyCode { use crossterm::event::KeyCode; match self { Self::Backspace => KeyCode::Backspace, Self::Enter => KeyCode::Enter, Self::Left => KeyCode::Left, Self::Right => KeyCode::Right, Self::Up => KeyCode::Up, Self::Down => KeyCode::Down, Self::Home => KeyCode::Home, Self::End => KeyCode::End, Self::PageUp => KeyCode::PageUp, Self::PageDown => KeyCode::PageDown, Self::Tab => KeyCode::Tab, Self::BackTab => KeyCode::BackTab, Self::Delete => KeyCode::Delete, Self::Insert => KeyCode::Insert, Self::Esc => KeyCode::Esc, Self::Char(ch) => KeyCode::Char(*ch), Self::F(n) => KeyCode::F(*n), Self::Null => KeyCode::Null, } } } impl ClientMouseButton { #[cfg(any(windows, test))] pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self { match button { crossterm::event::MouseButton::Left => Self::Left, crossterm::event::MouseButton::Right => Self::Right, crossterm::event::MouseButton::Middle => Self::Middle, } } pub(crate) fn to_crossterm(self) -> crossterm::event::MouseButton { match self { Self::Left => crossterm::event::MouseButton::Left, Self::Right => crossterm::event::MouseButton::Right, Self::Middle => crossterm::event::MouseButton::Middle, } } } impl ClientMouseKind { #[cfg(any(windows, test))] pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option { use crossterm::event::MouseEventKind; Some(match kind { MouseEventKind::Down(button) => Self::Down(ClientMouseButton::from_crossterm(button)), MouseEventKind::Up(button) => Self::Up(ClientMouseButton::from_crossterm(button)), MouseEventKind::Drag(button) => Self::Drag(ClientMouseButton::from_crossterm(button)), MouseEventKind::Moved => Self::Moved, MouseEventKind::ScrollUp => Self::ScrollUp, MouseEventKind::ScrollDown => Self::ScrollDown, MouseEventKind::ScrollLeft => Self::ScrollLeft, MouseEventKind::ScrollRight => Self::ScrollRight, }) } pub(crate) fn to_crossterm(self) -> crossterm::event::MouseEventKind { use crossterm::event::MouseEventKind; match self { Self::Down(button) => MouseEventKind::Down(button.to_crossterm()), Self::Up(button) => MouseEventKind::Up(button.to_crossterm()), Self::Drag(button) => MouseEventKind::Drag(button.to_crossterm()), Self::Moved => MouseEventKind::Moved, Self::ScrollUp => MouseEventKind::ScrollUp, Self::ScrollDown => MouseEventKind::ScrollDown, Self::ScrollLeft => MouseEventKind::ScrollLeft, Self::ScrollRight => MouseEventKind::ScrollRight, } } } impl ClientInputEvent { #[cfg(windows)] pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option { match event { crossterm::event::Event::Key(key) => Some(Self::Key { code: ClientKeyCode::from_crossterm(key.code)?, modifiers: key.modifiers.bits(), kind: ClientKeyKind::from_crossterm(key.kind), }), crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse { kind: ClientMouseKind::from_crossterm(mouse.kind)?, column: mouse.column, row: mouse.row, modifiers: mouse.modifiers.bits(), }), crossterm::event::Event::Paste(text) => Some(Self::Paste { text }), crossterm::event::Event::FocusGained => Some(Self::FocusGained), crossterm::event::Event::FocusLost => Some(Self::FocusLost), crossterm::event::Event::Resize(_, _) => None, } } pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent { match self { Self::Key { code, modifiers, kind, } => crate::raw_input::RawInputEvent::Key( crate::input::TerminalKey::new( code.to_crossterm(), crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), ) .with_kind(kind.to_crossterm()), ), Self::Mouse { kind, column, row, modifiers, } => crate::raw_input::RawInputEvent::Mouse(crossterm::event::MouseEvent { kind: kind.to_crossterm(), column: *column, row: *row, modifiers: crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), }), Self::Paste { text } => crate::raw_input::RawInputEvent::Paste(text.clone()), Self::FocusGained => crate::raw_input::RawInputEvent::OuterFocusGained, Self::FocusLost => crate::raw_input::RawInputEvent::OuterFocusLost, } } } /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { /// Handshake: client announces its protocol version and terminal dimensions. Hello { /// Protocol version the client speaks. version: u32, /// Terminal width in columns. cols: u16, /// Terminal height in rows. rows: u16, /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, /// Render encoding requested by the client. requested_encoding: RenderEncoding, /// Keybinding profile requested by the client. keybindings: ClientKeybindings, /// Whether this connection will render the full app or attach directly to a pane terminal. launch_mode: ClientLaunchMode, }, /// Raw input bytes read from the client's stdin. Input { /// Raw terminal input (possibly multi-byte escape sequences). data: Vec, }, /// Image bytes read from the client's local clipboard for remote paste bridging. ClipboardImage { /// Image file extension without a leading dot. extension: String, /// Raw image bytes. data: Vec, }, /// Terminal resize notification from the client. Resize { /// New terminal width in columns. cols: u16, /// New terminal height in rows. rows: u16, /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, }, /// Graceful disconnect request. Detach, /// Switch this connection into direct terminal attach mode. AttachTerminal { /// Terminal id to attach to. terminal_id: String, /// Replace an existing writable attach owner for this terminal. takeover: bool, }, /// Scroll input handled by a direct terminal attach client. AttachScroll { /// Original input source for routing. source: AttachScrollSource, /// Scroll direction. direction: AttachScrollDirection, /// Number of terminal rows to move when using host scrollback. lines: u16, /// Mouse column relative to the attached terminal, when available. column: Option, /// Mouse row relative to the attached terminal, when available. row: Option, /// Crossterm-compatible modifier bits for forwarded mouse wheel events. modifiers: u8, }, /// Structured input events from platform clients that do not expose Unix-style raw bytes. InputEvents { events: Vec }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AttachScrollDirection { Up, Down, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AttachScrollSource { Wheel, PageKey { /// Original key bytes to forward when the child application owns page keys. input: Vec, }, } // --------------------------------------------------------------------------- // Server → Client messages // --------------------------------------------------------------------------- /// A single cell in a rendered frame, serialized independently from ratatui's /// `Cell` type to keep the wire protocol stable. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CellData { /// Grapheme cluster displayed in this cell (usually 1–2 chars). pub symbol: String, /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index). pub fg: u32, /// Background color as a packed u32. pub bg: u32, /// Bitmask of style modifiers (bold, italic, etc.). pub modifier: u16, /// Whether this cell should be skipped during diff-based rendering. pub skip: bool, /// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any. pub hyperlink: Option, } /// Cursor shape encoded as a DECSCUSR parameter. /// /// 0 = terminal default, 1 = blinking block, 2 = steady block, /// 3 = blinking underline, 4 = steady underline, 5 = blinking bar, /// 6 = steady bar. pub type CursorShapeParam = u8; /// Cursor position within a rendered frame. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CursorState { /// Column offset (0-based) of the cursor. pub x: u16, /// Row offset (0-based) of the cursor. pub y: u16, /// Whether the cursor is visible. pub visible: bool, /// Cursor shape as a DECSCUSR parameter. #[serde(default)] pub shape: CursorShapeParam, } /// A rendered frame to be displayed by the client. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrameData { /// Cells in row-major order. Length must equal `width * height`. pub cells: Vec, /// Frame width in columns. pub width: u16, /// Frame height in rows. pub height: u16, /// Cursor state for this frame, if applicable. pub cursor: Option, /// OSC 8 hyperlink URIs referenced by cells. pub hyperlinks: Vec, /// Kitty graphics protocol bytes to apply after the text frame. pub graphics: Vec, } impl FrameData { /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor. /// /// This converts ratatui's internal cell representation into the /// wire-protocol cell format. The conversion is lossless for all /// commonly used cell attributes. #[cfg(test)] pub fn from_ratatui_buffer( buffer: &ratatui::buffer::Buffer, cursor: Option, ) -> Self { Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[]) } pub fn from_ratatui_buffer_with_hyperlinks( buffer: &ratatui::buffer::Buffer, cursor: Option, hyperlinks: &[((u16, u16), String, String)], ) -> Self { let area = buffer.area; let width = area.width; let height = area.height; let mut hyperlink_uris = Vec::::new(); let mut hyperlink_indices = HashMap::<&str, u32>::new(); let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new(); for ((x, y), symbol, uri) in hyperlinks { hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str())); } let mut cells = Vec::with_capacity((width as usize) * (height as usize)); for row in 0..height { for col in 0..width { let cell = buffer.cell((col, row)).expect("cell within bounds"); let hyperlink = hyperlink_by_position .get(&(col, row)) .and_then(|(symbol, uri)| { if *symbol != cell.symbol() { return None; } Some(*hyperlink_indices.entry(*uri).or_insert_with(|| { let index = hyperlink_uris.len() as u32; hyperlink_uris.push((*uri).to_owned()); index })) }); cells.push(CellData { symbol: cell.symbol().to_owned(), fg: color_to_u32(cell.fg), bg: color_to_u32(cell.bg), modifier: modifier_to_u16(cell.modifier), skip: cell.skip, hyperlink, }); } } FrameData { cells, width, height, cursor, hyperlinks: hyperlink_uris, graphics: Vec::new(), } } /// Reconstructs a ratatui `Buffer` from this frame data. /// /// Returns `None` if the cells vector length doesn't match `width * height`. #[cfg(test)] pub fn to_ratatui_buffer(&self) -> Option { let expected = (self.width as usize) * (self.height as usize); if self.cells.len() != expected { return None; } let area = ratatui::layout::Rect::new(0, 0, self.width, self.height); let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); for row in 0..self.height { for col in 0..self.width { let idx = (row as usize) * (self.width as usize) + (col as usize); let cell_data = &self.cells[idx]; let cell = buffer.cell_mut((col, row)).expect("cell within bounds"); cell.set_symbol(&cell_data.symbol); cell.fg = u32_to_color(cell_data.fg); cell.bg = u32_to_color(cell_data.bg); cell.modifier = u16_to_modifier(cell_data.modifier); cell.skip = cell_data.skip; } } Some(buffer) } } /// Terminal ANSI bytes encoded by the server for network-efficient clients. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TerminalFrame { /// Monotonic per-client frame sequence. pub seq: u64, /// Frame width in columns. pub width: u16, /// Frame height in rows. pub height: u16, /// Whether bytes contain a full redraw rather than an incremental diff. pub full: bool, /// Terminal escape bytes ready to write directly to stdout. pub bytes: Vec, } /// Notification kind forwarded from server to client. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum NotifyKind { /// Play a sound (bell/agent-done, etc.). Sound, /// Display a toast message through the outer terminal. Toast, /// Display a toast message through the host OS notification service. SystemToast, } /// Messages sent from the server to the client over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ServerMessage { /// Handshake response: server acknowledges (or rejects) the client. Welcome { /// Protocol version the server speaks. version: u32, /// Render encoding selected by the server for this connection. encoding: RenderEncoding, /// If present, the handshake failed and this describes why. /// The client should exit with a clear error message. error: Option, }, /// A rendered frame to be displayed by a semantic-frame client. Frame(FrameData), /// Terminal bytes to write directly for a terminal-ANSI client. Terminal(TerminalFrame), /// Client-local Kitty graphics bytes to write directly to the host terminal. Graphics { /// Raw Kitty graphics protocol bytes. bytes: Vec, }, /// Server is shutting down. Clients should exit gracefully. ServerShutdown { /// Optional reason for the shutdown. reason: Option, }, /// A notification event (sound/toast) to be rendered locally by the client. Notify { /// What kind of notification. kind: NotifyKind, /// Human-readable title or sound label. message: String, /// Optional human-readable notification body. body: Option, }, /// OSC 52 clipboard data forwarded from a PTY through the server. Clipboard { /// Base64-encoded clipboard data. data: String, }, /// Set the foreground client's outer terminal window title. WindowTitle { /// Sanitized title to write with OSC 0. `None` restores Herdr's default title. title: Option, }, /// Client-local runtime config changed on disk; refresh it without reconnecting. ReloadSoundConfig, /// Whether the client should currently capture host mouse input. MouseCapture { /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. enabled: bool, }, } // --------------------------------------------------------------------------- // Color / Modifier conversion helpers // --------------------------------------------------------------------------- /// Converts a ratatui `Color` to a packed u32 for wire transport. /// /// Encoding: /// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16 /// - Indexed palette → `0x01_00_00_XX` where XX is the palette index /// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes pub(crate) fn color_to_u32(color: ratatui::style::Color) -> u32 { match color { ratatui::style::Color::Reset => 0x00_00_00_00, ratatui::style::Color::Black => 0x00_00_00_01, ratatui::style::Color::Red => 0x00_00_00_02, ratatui::style::Color::Green => 0x00_00_00_03, ratatui::style::Color::Yellow => 0x00_00_00_04, ratatui::style::Color::Blue => 0x00_00_00_05, ratatui::style::Color::Magenta => 0x00_00_00_06, ratatui::style::Color::Cyan => 0x00_00_00_07, ratatui::style::Color::Gray => 0x00_00_00_08, ratatui::style::Color::DarkGray => 0x00_00_00_09, ratatui::style::Color::LightRed => 0x00_00_00_0A, ratatui::style::Color::LightGreen => 0x00_00_00_0B, ratatui::style::Color::LightYellow => 0x00_00_00_0C, ratatui::style::Color::LightBlue => 0x00_00_00_0D, ratatui::style::Color::LightMagenta => 0x00_00_00_0E, ratatui::style::Color::LightCyan => 0x00_00_00_0F, ratatui::style::Color::White => 0x00_00_00_10, ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32), ratatui::style::Color::Rgb(r, g, b) => { 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32) } } } /// Converts a packed u32 back to a ratatui `Color`. #[cfg(test)] fn u32_to_color(val: u32) -> ratatui::style::Color { match val >> 24 { 0x00 => match val & 0xFF { 0x00 => ratatui::style::Color::Reset, 0x01 => ratatui::style::Color::Black, 0x02 => ratatui::style::Color::Red, 0x03 => ratatui::style::Color::Green, 0x04 => ratatui::style::Color::Yellow, 0x05 => ratatui::style::Color::Blue, 0x06 => ratatui::style::Color::Magenta, 0x07 => ratatui::style::Color::Cyan, 0x08 => ratatui::style::Color::Gray, 0x09 => ratatui::style::Color::DarkGray, 0x0A => ratatui::style::Color::LightRed, 0x0B => ratatui::style::Color::LightGreen, 0x0C => ratatui::style::Color::LightYellow, 0x0D => ratatui::style::Color::LightBlue, 0x0E => ratatui::style::Color::LightMagenta, 0x0F => ratatui::style::Color::LightCyan, 0x10 => ratatui::style::Color::White, _ => ratatui::style::Color::Reset, // unknown named → Reset }, 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8), 0x02 => { let r = ((val >> 16) & 0xFF) as u8; let g = ((val >> 8) & 0xFF) as u8; let b = (val & 0xFF) as u8; ratatui::style::Color::Rgb(r, g, b) } _ => ratatui::style::Color::Reset, // unknown tag → Reset } } /// Converts a ratatui `Modifier` bitmask to a u16 for wire transport. pub(crate) fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 { modifier.bits() } /// Converts a u16 back to a ratatui `Modifier`. #[cfg(test)] fn u16_to_modifier(val: u16) -> ratatui::style::Modifier { ratatui::style::Modifier::from_bits_truncate(val) } // --------------------------------------------------------------------------- // Framing: length-prefixed binary messages // --------------------------------------------------------------------------- /// Errors that can occur during framing operations. #[derive(Debug)] pub enum FramingError { /// The decoded payload length exceeds the configured maximum frame size. Oversized { claimed: usize, max: usize }, /// An I/O error occurred while reading or writing. Io(io::Error), /// Bincode serialization or deserialization failed. Bincode(String), /// The connection was closed before a complete frame could be read. UnexpectedEof, } impl std::fmt::Display for FramingError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { FramingError::Oversized { claimed, max } => { write!(f, "frame size {claimed} exceeds maximum {max}") } FramingError::Io(e) => write!(f, "I/O error: {e}"), FramingError::Bincode(e) => write!(f, "bincode error: {e}"), FramingError::UnexpectedEof => write!(f, "unexpected end of stream"), } } } impl std::error::Error for FramingError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { FramingError::Io(e) => Some(e), _ => None, } } } impl From for FramingError { fn from(e: io::Error) -> Self { FramingError::Io(e) } } /// Serializes a message and writes it as a length-prefixed frame: /// `[u32LE length][bincode payload]`. /// /// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream` /// in blocking mode, or with any `Write` implementor. /// /// # Errors /// /// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX` /// (would be truncated by the length prefix cast). pub fn write_message(writer: &mut W, msg: &M) -> Result<(), FramingError> { let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard()) .map_err(|e| FramingError::Bincode(e.to_string()))?; let len = payload.len(); if len > u32::MAX as usize { return Err(FramingError::Bincode(format!( "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix", u32::MAX ))); } writer.write_all(&(len as u32).to_le_bytes())?; writer.write_all(&payload)?; writer.flush()?; Ok(()) } /// Reads and deserializes a length-prefixed frame from a reader. /// /// Reassembles partial reads correctly. Rejects frames whose declared /// length exceeds `max_frame_size` without panicking or allocating /// oversized buffers. pub fn read_message Deserialize<'de>>( reader: &mut R, max_frame_size: usize, ) -> Result { // Read the 4-byte length prefix, reassembling partial reads. let mut len_buf = [0u8; LENGTH_PREFIX_BYTES]; read_exact_or_eof(reader, &mut len_buf)?; let claimed_len = u32::from_le_bytes(len_buf) as usize; if claimed_len > max_frame_size { return Err(FramingError::Oversized { claimed: claimed_len, max: max_frame_size, }); } // Read the payload, reassembling partial reads. let mut payload = vec![0u8; claimed_len]; read_exact_or_eof(reader, &mut payload)?; let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard()) .map_err(|e| FramingError::Bincode(e.to_string()))?; // Enforce that the decoder consumed the full payload. // Trailing bytes after the decoded message indicate a protocol violation // (e.g., a corrupted length prefix or concatenated payloads). if consumed != claimed_len { return Err(FramingError::Bincode(format!( "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed", consumed ))); } Ok(msg) } /// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof` /// when the reader hits end-of-stream before filling the buffer, instead /// of the generic `io::ErrorKind::UnexpectedEof`. fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> { reader.read_exact(buf).map_err(|e| { if e.kind() == io::ErrorKind::UnexpectedEof { FramingError::UnexpectedEof } else { FramingError::Io(e) } }) } // --------------------------------------------------------------------------- // Version negotiation // --------------------------------------------------------------------------- /// Result of checking a client's protocol version against the server's. #[derive(Debug, Clone, PartialEq, Eq)] pub enum VersionCheck { /// Versions are compatible. The server should reply with a successful Welcome. Compatible, /// Versions are incompatible. The server should reply with a Welcome error and close. Incompatible(String), } /// Checks whether a client's protocol version is compatible with this server. /// /// Current rules: /// - Version 0 (pre-persistence client) is always rejected. /// - Matching major versions are accepted. /// - A client with a newer version than the server is rejected. /// - A client with an older version than the server is rejected /// (backward compatibility is not yet supported). pub fn check_client_version(client_version: u32) -> VersionCheck { if client_version == 0 { return VersionCheck::Incompatible( "pre-persistence client (version 0) is not supported".to_owned(), ); } if client_version == PROTOCOL_VERSION { VersionCheck::Compatible } else if client_version < PROTOCOL_VERSION { VersionCheck::Incompatible(format!( "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client" )) } else { VersionCheck::Incompatible(format!( "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server" )) } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use ratatui::style::{Color, Modifier}; // ---- Round-trip: ClientMessage ---- #[test] fn client_hello_roundtrip() { let msg = ClientMessage::Hello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_input_roundtrip() { let msg = ClientMessage::Input { data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow) }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_message_wire_tags_preserve_protocol_14_order() { fn tag(msg: &ClientMessage) -> u8 { *bincode::serde::encode_to_vec(msg, bincode::config::standard()) .unwrap() .first() .expect("encoded client message should include enum tag") } assert_eq!( tag(&ClientMessage::Hello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }), 0 ); assert_eq!(tag(&ClientMessage::Input { data: Vec::new() }), 1); assert_eq!( tag(&ClientMessage::ClipboardImage { extension: "png".to_owned(), data: Vec::new(), }), 2 ); assert_eq!( tag(&ClientMessage::Resize { cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, }), 3 ); assert_eq!(tag(&ClientMessage::Detach), 4); assert_eq!( tag(&ClientMessage::AttachTerminal { terminal_id: "term".to_owned(), takeover: false, }), 5 ); assert_eq!( tag(&ClientMessage::AttachScroll { source: AttachScrollSource::Wheel, direction: AttachScrollDirection::Up, lines: 1, column: None, row: None, modifiers: 0, }), 6 ); assert_eq!(tag(&ClientMessage::InputEvents { events: Vec::new() }), 7); } #[test] fn client_input_events_roundtrip() { let msg = ClientMessage::InputEvents { events: vec![ ClientInputEvent::Key { code: ClientKeyCode::Char('N'), modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, }, ClientInputEvent::Key { code: ClientKeyCode::Backspace, modifiers: 0, kind: ClientKeyKind::Press, }, ClientInputEvent::Mouse { kind: ClientMouseKind::Down(ClientMouseButton::Left), column: 3, row: 4, modifiers: 0, }, ], }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_input_events_convert_to_raw_keys() { let shifted = ClientInputEvent::Key { code: ClientKeyCode::Char('N'), modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, } .to_raw_input_event(); match shifted { crate::raw_input::RawInputEvent::Key(key) => { assert_eq!(key.code, crossterm::event::KeyCode::Char('N')); assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT); assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); } other => panic!("expected shifted key event, got {other:?}"), } let backspace = ClientInputEvent::Key { code: ClientKeyCode::Backspace, modifiers: 0, kind: ClientKeyKind::Press, } .to_raw_input_event(); match backspace { crate::raw_input::RawInputEvent::Key(key) => { assert_eq!(key.code, crossterm::event::KeyCode::Backspace); assert_eq!(key.modifiers, crossterm::event::KeyModifiers::empty()); assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); } other => panic!("expected backspace key event, got {other:?}"), } } #[test] fn client_clipboard_image_roundtrip() { let msg = ClientMessage::ClipboardImage { extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_input_large_multilingual_payload_roundtrip() { let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024); assert!(text.len() > 64 * 1024); assert!(text.len() < MAX_FRAME_SIZE); let msg = ClientMessage::Input { data: text.as_bytes().to_vec(), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, consumed): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(consumed, encoded.len()); assert_eq!(decoded, msg); } #[test] fn client_resize_roundtrip() { let msg = ClientMessage::Resize { cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_detach_roundtrip() { let msg = ClientMessage::Detach; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_attach_terminal_roundtrip() { let msg = ClientMessage::AttachTerminal { terminal_id: "term_123".to_owned(), takeover: true, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn client_attach_scroll_roundtrip() { let msg = ClientMessage::AttachScroll { source: AttachScrollSource::Wheel, direction: AttachScrollDirection::Up, lines: 3, column: Some(12), row: Some(7), modifiers: 4, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } // ---- Round-trip: ServerMessage ---- #[test] fn server_welcome_roundtrip() { let msg = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, error: None, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_welcome_with_error_roundtrip() { let msg = ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, error: Some("incompatible version".to_owned()), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_frame_roundtrip_nontrivial() { // Build a 3×2 frame with varied styles (≥2×2). let frame = FrameData { cells: vec![ CellData { symbol: "H".into(), fg: color_to_u32(Color::Red), bg: color_to_u32(Color::Black), modifier: Modifier::BOLD.bits(), skip: false, hyperlink: None, }, CellData { symbol: "i".into(), fg: color_to_u32(Color::Green), bg: color_to_u32(Color::Reset), modifier: Modifier::ITALIC.bits(), skip: false, hyperlink: None, }, CellData { symbol: "!".into(), fg: color_to_u32(Color::Rgb(255, 128, 0)), bg: color_to_u32(Color::Indexed(220)), modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(), skip: false, hyperlink: Some(0), }, CellData { symbol: " ".into(), fg: color_to_u32(Color::Reset), bg: color_to_u32(Color::Reset), modifier: Modifier::empty().bits(), skip: true, hyperlink: None, }, CellData { symbol: "→".into(), // multi-byte grapheme fg: color_to_u32(Color::Cyan), bg: color_to_u32(Color::Blue), modifier: Modifier::REVERSED.bits(), skip: false, hyperlink: None, }, CellData { symbol: "🦀".into(), // emoji, wide grapheme cluster fg: color_to_u32(Color::Yellow), bg: color_to_u32(Color::Magenta), modifier: Modifier::empty().bits(), skip: false, hyperlink: None, }, ], width: 3, height: 2, cursor: Some(CursorState { x: 0, y: 0, visible: true, shape: 6, }), hyperlinks: vec!["https://example.com".to_owned()], graphics: Vec::new(), }; let msg = ServerMessage::Frame(frame.clone()); let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); match decoded { ServerMessage::Frame(frame) => { assert_eq!(frame.cells[2].hyperlink, Some(0)); assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]); } other => panic!("expected frame, got {other:?}"), } } #[test] fn server_shutdown_roundtrip() { let msg = ServerMessage::ServerShutdown { reason: Some("updating".to_owned()), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_notify_roundtrip() { for kind in [ NotifyKind::Sound, NotifyKind::Toast, NotifyKind::SystemToast, ] { let msg = ServerMessage::Notify { kind, message: "agent done".to_owned(), body: None, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } } #[test] fn server_clipboard_roundtrip() { let msg = ServerMessage::Clipboard { data: "dGVzdA==".to_owned(), // base64 "test" }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_window_title_roundtrip() { for title in [Some("herdr api".to_owned()), None] { let msg = ServerMessage::WindowTitle { title }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } } #[test] fn server_graphics_roundtrip() { let msg = ServerMessage::Graphics { bytes: b"\x1b_Ga=d,d=A,q=2;\x1b\\".to_vec(), }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_terminal_frame_roundtrip() { let msg = ServerMessage::Terminal(TerminalFrame { seq: 7, width: 120, height: 40, full: false, bytes: b"\x1b[1;1Hhello".to_vec(), }); let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_reload_sound_config_roundtrip() { let msg = ServerMessage::ReloadSoundConfig; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } #[test] fn server_mouse_capture_roundtrip() { let msg = ServerMessage::MouseCapture { enabled: true }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ServerMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } // ---- Framing ---- #[test] fn framing_small_message_roundtrip() { let msg = ClientMessage::Hello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); assert_eq!(msg, decoded); } #[test] fn framing_large_payload_roundtrip() { // Create a Frame message that is ≥128 KB. // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding. // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB. let width: u16 = 200; let height: u16 = 50; let cells: Vec = (0..(width as usize) * (height as usize)) .map(|i| CellData { symbol: if i % 256 < 32 { " ".to_owned() } else { format!("{:03}", i % 1000) }, fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)), bg: color_to_u32(Color::Indexed((i % 256) as u8)), modifier: ((i % 16) as u16), skip: i % 100 == 0, hyperlink: None, }) .collect(); let frame = FrameData { cells, width, height, cursor: Some(CursorState { x: 10, y: 5, visible: true, shape: 0, }), hyperlinks: Vec::new(), graphics: Vec::new(), }; let msg = ServerMessage::Frame(frame); let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); // Verify the payload is at least 128 KB assert!( buf.len() >= 128 * 1024, "framed payload should be >= 128 KB, got {} bytes", buf.len() ); let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); assert_eq!(msg, decoded); } #[test] fn framing_multiple_messages_sequential() { // Write 100+ messages of varying types and read them back. let mut buf = Vec::new(); let mut expected = Vec::new(); for i in 0..150u32 { let msg = match i % 5 { 0 => ClientMessage::Hello { version: PROTOCOL_VERSION, cols: (80 + (i % 40) as u16), rows: (24 + (i % 20) as u16), cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], }, 2 => ClientMessage::ClipboardImage { extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G', (i % 256) as u8], }, 3 => ClientMessage::Resize { cols: (100 + (i % 30) as u16), rows: (30 + (i % 10) as u16), cell_width_px: 8, cell_height_px: 16, }, 4 => ClientMessage::Detach, _ => unreachable!(), }; write_message(&mut buf, &msg).unwrap(); expected.push(msg); } let mut cursor = buf.as_slice(); for expected_msg in &expected { let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap(); assert_eq!(*expected_msg, decoded); } } #[test] fn framing_oversized_rejected_without_panic() { // Craft a frame with a huge length prefix (4 GB claim). let mut buf: Vec = (u32::MAX).to_le_bytes().to_vec(); // Add a few garbage bytes after the length prefix. buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); match result { Err(FramingError::Oversized { claimed, max }) => { assert_eq!(claimed, u32::MAX as usize); assert_eq!(max, MAX_FRAME_SIZE); } other => panic!("expected Oversized error, got: {other:?}"), } } #[test] fn framing_malformed_payload_rejected_without_panic() { // Valid length prefix pointing to garbage data. let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02]; let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); buf.extend_from_slice(&payload); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); assert!(result.is_err(), "malformed payload should be rejected"); match result { Err(FramingError::Bincode(_)) => {} // expected other => panic!("expected Bincode error, got: {other:?}"), } } #[test] fn framing_truncated_stream_returns_unexpected_eof() { // Write a length prefix claiming 100 bytes, but only provide 4. let mut buf: Vec = 100u32.to_le_bytes().to_vec(); buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); match result { Err(FramingError::UnexpectedEof) => {} other => panic!("expected UnexpectedEof, got: {other:?}"), } } #[test] fn framing_zero_length_message() { // A 1-byte message (smallest possible valid bincode payload). // Actually, let's test with the smallest real message: Detach. let msg = ClientMessage::Detach; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); // Verify the length prefix is correct let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; assert_eq!( len, buf.len() - 4, "length prefix should match payload size" ); let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); assert_eq!(msg, decoded); } #[test] fn framing_partial_read_reassembly() { // Simulate partial reads by using a reader that yields small chunks. let msg = ClientMessage::Input { data: vec![42; 500], // 500-byte input payload }; let mut full_buf = Vec::new(); write_message(&mut full_buf, &msg).unwrap(); // Wrap in a chunked reader that only yields 7 bytes at a time. let mut chunked = ChunkedReader::new(full_buf, 7); let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap(); assert_eq!(msg, decoded); } // ---- Version negotiation ---- #[test] fn version_compatible() { assert_eq!( check_client_version(PROTOCOL_VERSION), VersionCheck::Compatible ); } #[test] fn version_older_client_rejected() { let result = check_client_version(PROTOCOL_VERSION - 1); assert!(matches!(result, VersionCheck::Incompatible(_))); if let VersionCheck::Incompatible(msg) = result { assert!(msg.contains("older"), "error should mention older version"); } } #[test] fn version_newer_client_rejected() { let result = check_client_version(PROTOCOL_VERSION + 1); assert!(matches!(result, VersionCheck::Incompatible(_))); if let VersionCheck::Incompatible(msg) = result { assert!(msg.contains("newer"), "error should mention newer version"); } } // ---- Pre-persistence client rejection ---- #[test] fn prepersistence_version_zero_rejected() { let result = check_client_version(0); match result { VersionCheck::Incompatible(msg) => { assert!( msg.contains("pre-persistence"), "error should mention pre-persistence: {msg}" ); } _ => panic!("version 0 should be rejected as incompatible"), } } #[test] fn prepersistence_version_zero_welcome_has_error() { // Simulating what the server would send to a v0 client. let check = check_client_version(0); let response = match check { VersionCheck::Compatible => ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, error: None, }, VersionCheck::Incompatible(reason) => ServerMessage::Welcome { version: PROTOCOL_VERSION, encoding: RenderEncoding::SemanticFrame, error: Some(reason), }, }; match response { ServerMessage::Welcome { error: Some(_), .. } => {} other => panic!("expected Welcome with error, got: {other:?}"), } } // ---- Malformed/oversized input ---- #[test] fn oversized_frame_does_not_panic() { // Claim 4GB payload — should return Oversized error, not panic. let mut buf: Vec = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim buf.extend_from_slice(&[0; 8]); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); assert!(result.is_err()); // Did not panic — test passing is proof. } #[test] fn malformed_frame_does_not_panic() { // Random garbage bytes after a valid-ish length prefix. let garbage: Vec = (0..200).map(|i| (i ^ 0xAA) as u8).collect(); let mut buf = (garbage.len() as u32).to_le_bytes().to_vec(); buf.extend_from_slice(&garbage); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); assert!(result.is_err()); // Did not panic. } #[test] fn oversized_input_rejected_custom_max() { // Verify a custom (small) max_frame_size is enforced. let msg = ClientMessage::Input { data: vec![0x41; 1000], }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); let result: Result = read_message(&mut buf.as_slice(), 64); // The actual bincode payload for 1000 bytes of input will be > 64 bytes. assert!( matches!(result, Err(FramingError::Oversized { .. })), "expected Oversized with small max_frame_size" ); } // ---- FrameData ↔ ratatui Buffer conversion ---- #[test] fn frame_data_roundtrip_through_ratatui_buffer() { let area = ratatui::layout::Rect::new(0, 0, 5, 3); let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" ")); // Write some styled content. buffer.cell_mut((0, 0)).unwrap().set_symbol("H"); buffer.cell_mut((0, 0)).unwrap().fg = Color::Red; buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD; buffer.cell_mut((1, 0)).unwrap().set_symbol("i"); buffer.cell_mut((1, 0)).unwrap().fg = Color::Green; buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC; buffer.cell_mut((2, 0)).unwrap().set_symbol("!"); buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0); buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220); let cursor = CursorState { x: 1, y: 0, visible: true, shape: 0, }; let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone())); // Verify frame dimensions. assert_eq!(frame.width, 5); assert_eq!(frame.height, 3); assert_eq!(frame.cells.len(), 15); assert_eq!(frame.cursor, Some(cursor)); // Verify specific cells survived the conversion. assert_eq!(frame.cells[0].symbol, "H"); assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red)); assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits()); assert_eq!(frame.cells[1].symbol, "i"); assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green)); assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits()); assert_eq!(frame.cells[2].symbol, "!"); assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0))); assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220))); let with_links = FrameData::from_ratatui_buffer_with_hyperlinks( &buffer, None, &[((1, 0), "i".to_owned(), "https://example.com".to_owned())], ); assert_eq!(with_links.cells[1].hyperlink, Some(0)); assert_eq!( with_links.hyperlinks, vec!["https://example.com".to_owned()] ); // Convert back to ratatui buffer and compare. let restored = frame.to_ratatui_buffer().expect("should reconstruct"); assert_eq!(restored.area, area); assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H"); assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red); assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD); assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i"); assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!"); assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0)); } #[test] fn frame_data_rejects_mismatched_cell_count() { let frame = FrameData { cells: vec![ CellData { symbol: "X".into(), fg: 0, bg: 0, modifier: 0, skip: false, hyperlink: None, }; 5 ], // 5 cells but 3×2 = 6 expected width: 3, height: 2, cursor: None, hyperlinks: Vec::new(), graphics: Vec::new(), }; assert!(frame.to_ratatui_buffer().is_none()); } // ---- Color conversion coverage ---- #[test] fn color_roundtrip_all_named_colors() { let named = [ Color::Reset, Color::Black, Color::Red, Color::Green, Color::Yellow, Color::Blue, Color::Magenta, Color::Cyan, Color::Gray, Color::DarkGray, Color::LightRed, Color::LightGreen, Color::LightYellow, Color::LightBlue, Color::LightMagenta, Color::LightCyan, Color::White, ]; for c in named { assert_eq!( u32_to_color(color_to_u32(c)), c, "roundtrip failed for {c:?}" ); } } #[test] fn color_roundtrip_indexed() { for i in 0..=255u8 { let c = Color::Indexed(i); assert_eq!( u32_to_color(color_to_u32(c)), c, "roundtrip failed for Indexed({i})" ); } } #[test] fn color_roundtrip_rgb() { let c = Color::Rgb(0xAB, 0xCD, 0xEF); assert_eq!(u32_to_color(color_to_u32(c)), c); let c = Color::Rgb(0, 0, 0); assert_eq!(u32_to_color(color_to_u32(c)), c); let c = Color::Rgb(255, 255, 255); assert_eq!(u32_to_color(color_to_u32(c)), c); } // ---- Modifier conversion ---- #[test] fn modifier_roundtrip() { let all_mods = [ Modifier::BOLD, Modifier::ITALIC, Modifier::REVERSED, Modifier::UNDERLINED, Modifier::DIM, Modifier::SLOW_BLINK, Modifier::CROSSED_OUT, Modifier::BOLD | Modifier::ITALIC, Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED, Modifier::empty(), ]; for m in all_mods { assert_eq!( u16_to_modifier(modifier_to_u16(m)), m, "roundtrip failed for {m:?}" ); } } #[test] fn read_message_rejects_trailing_bytes() { // Encode a valid message, then append an extra byte after it. let msg = ClientMessage::Detach; let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let original_len = payload.len(); payload.push(0xDE); // trailing garbage // Frame it with the inflated length (original + 1). let mut buf = (payload.len() as u32).to_le_bytes().to_vec(); buf.extend_from_slice(&payload); let result: Result = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE); match result { Err(FramingError::Bincode(msg)) => { assert!( msg.contains("trailing bytes"), "error should mention trailing bytes: {msg}" ); assert!( msg.contains(&format!("decoded {original_len}")), "error should mention decoded byte count: {msg}" ); } other => panic!("expected Bincode error about trailing bytes, got: {other:?}"), } } #[test] fn read_message_accepts_exact_payload() { // A normally-framed message should decode without error. let msg = ClientMessage::Hello { version: PROTOCOL_VERSION, cols: 80, rows: 24, cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap(); assert_eq!(msg, decoded); } #[test] fn write_message_rejects_oversized_payload() { // We can't easily create a message that exceeds u32::MAX in a test, // but we can verify the check exists by testing that normal messages // have lengths well within the limit and the function doesn't fail. let msg = ClientMessage::Detach; let mut buf = Vec::new(); assert!(write_message(&mut buf, &msg).is_ok()); } // ---- Unix socketpair integration test ---- #[cfg(unix)] #[test] fn framing_over_unix_socketpair() { use std::os::unix::net::UnixStream; let (mut a, mut b) = UnixStream::pair().expect("socketpair"); let messages = vec![ ClientMessage::Hello { version: PROTOCOL_VERSION, cols: 200, rows: 60, cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, keybindings: ClientKeybindings::Server, launch_mode: ClientLaunchMode::App, }, ClientMessage::Input { data: b"hello world".to_vec(), }, ClientMessage::ClipboardImage { extension: "png".to_owned(), data: vec![0x89, b'P', b'N', b'G'], }, ClientMessage::Resize { cols: 100, rows: 30, cell_width_px: 8, cell_height_px: 16, }, ClientMessage::Detach, ]; // Set non-blocking so we can write and read in the same test. a.set_nonblocking(false).unwrap(); b.set_nonblocking(false).unwrap(); for msg in &messages { write_message(&mut a, msg).unwrap(); } for expected in &messages { let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap(); assert_eq!(*expected, decoded); } } // ---- Helper: chunked reader for simulating partial reads ---- /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call, /// simulating partial reads on a real socket. struct ChunkedReader { data: Vec, pos: usize, chunk_size: usize, } impl ChunkedReader { fn new(data: Vec, chunk_size: usize) -> Self { Self { data, pos: 0, chunk_size, } } } impl Read for ChunkedReader { fn read(&mut self, buf: &mut [u8]) -> io::Result { if self.pos >= self.data.len() { return Ok(0); } let remaining = self.data.len() - self.pos; let to_read = buf.len().min(remaining).min(self.chunk_size); buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]); self.pos += to_read; Ok(to_read) } } }