diff options
Diffstat (limited to 'src/server/protocol.rs')
| -rw-r--r-- | src/server/protocol.rs | 1212 |
1 files changed, 1212 insertions, 0 deletions
diff --git a/src/server/protocol.rs b/src/server/protocol.rs new file mode 100644 index 00000000..f3784a5e --- /dev/null +++ b/src/server/protocol.rs @@ -0,0 +1,1212 @@ +//! 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::io::{self, Read, Write}; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Protocol constants +// --------------------------------------------------------------------------- + +/// Current protocol version. Bumped when wire format changes incompatibly. +pub const PROTOCOL_VERSION: u32 = 1; + +/// 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; + +/// Length of the u32 little-endian length prefix in bytes. +const LENGTH_PREFIX_BYTES: usize = 4; + +// --------------------------------------------------------------------------- +// Client → Server messages +// --------------------------------------------------------------------------- + +/// 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, + }, + + /// Raw input bytes read from the client's stdin. + Input { + /// Raw terminal input (possibly multi-byte escape sequences). + data: Vec<u8>, + }, + + /// Terminal resize notification from the client. + Resize { + /// New terminal width in columns. + cols: u16, + /// New terminal height in rows. + rows: u16, + }, + + /// Graceful disconnect request. + Detach, +} + +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, +} + +/// 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<CellData>, + /// Frame width in columns. + pub width: u16, + /// Frame height in rows. + pub height: u16, + /// Cursor state for this frame, if applicable. + pub cursor: Option<CursorState>, +} + +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. + pub fn from_ratatui_buffer( + buffer: &ratatui::buffer::Buffer, + cursor: Option<CursorState>, + ) -> Self { + let area = buffer.area; + let width = area.width; + let height = area.height; + + 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"); + 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, + }); + } + } + + FrameData { + cells, + width, + height, + cursor, + } + } + + /// 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<ratatui::buffer::Buffer> { + 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) + } +} + +/// 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. + Toast, +} + +/// 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, + /// If present, the handshake failed and this describes why. + /// The client should exit with a clear error message. + error: Option<String>, + }, + + /// A rendered frame to be displayed by the client. + Frame(FrameData), + + /// Server is shutting down. Clients should exit gracefully. + ServerShutdown { + /// Optional reason for the shutdown. + reason: Option<String>, + }, + + /// A notification event (sound/toast) to be rendered locally by the client. + Notify { + /// What kind of notification. + kind: NotifyKind, + /// Human-readable message. + message: String, + }, + + /// OSC 52 clipboard data forwarded from a PTY through the server. + Clipboard { + /// Base64-encoded clipboard data. + data: String, + }, +} + +// --------------------------------------------------------------------------- +// 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 +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. +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<io::Error> 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<W: Write, M: Serialize>(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<R: Read, M: for<'de> Deserialize<'de>>( + reader: &mut R, + max_frame_size: usize, +) -> Result<M, FramingError> { + // 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<R: Read>(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 `Welcome { version, error: None }`. + Compatible, + /// Versions are incompatible. The server should reply + /// `Welcome { version, error: Some(reason) }` and close the connection. + 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: 1, + cols: 80, + rows: 24, + }; + 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_resize_roundtrip() { + let msg = ClientMessage::Resize { cols: 80, rows: 24 }; + 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); + } + + // ---- Round-trip: ServerMessage ---- + + #[test] + fn server_welcome_roundtrip() { + let msg = ServerMessage::Welcome { + version: 1, + 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: 1, + 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, + }, + CellData { + symbol: "i".into(), + fg: color_to_u32(Color::Green), + bg: color_to_u32(Color::Reset), + modifier: Modifier::ITALIC.bits(), + skip: false, + }, + 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, + }, + CellData { + symbol: " ".into(), + fg: color_to_u32(Color::Reset), + bg: color_to_u32(Color::Reset), + modifier: Modifier::empty().bits(), + skip: true, + }, + CellData { + symbol: "→".into(), // multi-byte grapheme + fg: color_to_u32(Color::Cyan), + bg: color_to_u32(Color::Blue), + modifier: Modifier::REVERSED.bits(), + skip: false, + }, + 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, + }, + ], + width: 3, + height: 2, + cursor: Some(CursorState { + x: 0, + y: 0, + visible: true, + }), + }; + 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); + } + + #[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() { + let msg = ServerMessage::Notify { + kind: NotifyKind::Sound, + message: "agent done".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_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); + } + + // ---- Framing ---- + + #[test] + fn framing_small_message_roundtrip() { + let msg = ClientMessage::Hello { + version: 1, + cols: 80, + rows: 24, + }; + 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<CellData> = (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, + }) + .collect(); + + let frame = FrameData { + cells, + width, + height, + cursor: Some(CursorState { + x: 10, + y: 5, + visible: true, + }), + }; + 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 % 4 { + 0 => ClientMessage::Hello { + version: 1, + cols: (80 + (i % 40) as u16), + rows: (24 + (i % 20) as u16), + }, + 1 => ClientMessage::Input { + data: vec![(i % 256) as u8; (i as usize % 50) + 1], + }, + 2 => ClientMessage::Resize { + cols: (100 + (i % 30) as u16), + rows: (30 + (i % 10) as u16), + }, + 3 => 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<u8> = (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<ClientMessage, FramingError> = + 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<ClientMessage, FramingError> = + 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<u8> = 100u32.to_le_bytes().to_vec(); + buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + + let result: Result<ClientMessage, FramingError> = + 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(1), VersionCheck::Compatible); + } + + #[test] + fn version_older_client_rejected() { + match check_client_version(1) { + VersionCheck::Compatible => {} // current version + _ => panic!("current version should be compatible"), + } + // If we imagine a future server version 2, a v1 client would be rejected. + // We test this by checking the logic directly. + let result = check_client_version(0); + assert!( + matches!(result, VersionCheck::Incompatible(_)), + "version 0 should be incompatible" + ); + } + + #[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, + error: None, + }, + VersionCheck::Incompatible(reason) => ServerMessage::Welcome { + version: PROTOCOL_VERSION, + 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<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim + buf.extend_from_slice(&[0; 8]); + + let result: Result<ClientMessage, FramingError> = + 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<u8> = (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<ClientMessage, FramingError> = + 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<ClientMessage, FramingError> = 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, + }; + 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))); + + // 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, + }; + 5 + ], // 5 cells but 3×2 = 6 expected + width: 3, + height: 2, + cursor: None, + }; + 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<ClientMessage, FramingError> = + 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: 1, + cols: 80, + rows: 24, + }; + 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 ---- + + #[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: 1, + cols: 200, + rows: 60, + }, + ClientMessage::Input { + data: b"hello world".to_vec(), + }, + ClientMessage::Resize { + cols: 100, + rows: 30, + }, + 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<u8>, + pos: usize, + chunk_size: usize, + } + + impl ChunkedReader { + fn new(data: Vec<u8>, chunk_size: usize) -> Self { + Self { + data, + pos: 0, + chunk_size, + } + } + } + + impl Read for ChunkedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + 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) + } + } +} |
