mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
Add SSH stdio remote attach, negotiate terminal-ANSI render encoding, move blit encoding server-side for remote clients, and split control/render writer delivery for safer backpressure. Fixes #76
1339 lines
48 KiB
Rust
1339 lines
48 KiB
Rust
//! 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 = 4;
|
||
|
||
/// 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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,
|
||
/// Render encoding requested by the client.
|
||
requested_encoding: RenderEncoding,
|
||
},
|
||
|
||
/// 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,
|
||
/// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any.
|
||
pub hyperlink: Option<u32>,
|
||
}
|
||
|
||
/// 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>,
|
||
/// OSC 8 hyperlink URIs referenced by cells.
|
||
pub hyperlinks: Vec<String>,
|
||
}
|
||
|
||
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<CursorState>,
|
||
) -> Self {
|
||
Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[])
|
||
}
|
||
|
||
pub fn from_ratatui_buffer_with_hyperlinks(
|
||
buffer: &ratatui::buffer::Buffer,
|
||
cursor: Option<CursorState>,
|
||
hyperlinks: &[((u16, u16), String, String)],
|
||
) -> Self {
|
||
let area = buffer.area;
|
||
let width = area.width;
|
||
let height = area.height;
|
||
|
||
let mut hyperlink_uris = Vec::<String>::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,
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
|
||
/// 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<u8>,
|
||
}
|
||
|
||
/// 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,
|
||
/// 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<String>,
|
||
},
|
||
|
||
/// 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),
|
||
|
||
/// 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,
|
||
},
|
||
|
||
/// Client-local sound config changed on disk; refresh it without reconnecting.
|
||
ReloadSoundConfig,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 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,
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
};
|
||
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: 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,
|
||
}),
|
||
hyperlinks: vec!["https://example.com".to_owned()],
|
||
};
|
||
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() {
|
||
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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
// ---- Framing ----
|
||
|
||
#[test]
|
||
fn framing_small_message_roundtrip() {
|
||
let msg = ClientMessage::Hello {
|
||
version: PROTOCOL_VERSION,
|
||
cols: 80,
|
||
rows: 24,
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
};
|
||
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,
|
||
hyperlink: None,
|
||
})
|
||
.collect();
|
||
|
||
let frame = FrameData {
|
||
cells,
|
||
width,
|
||
height,
|
||
cursor: Some(CursorState {
|
||
x: 10,
|
||
y: 5,
|
||
visible: true,
|
||
}),
|
||
hyperlinks: 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 % 4 {
|
||
0 => ClientMessage::Hello {
|
||
version: PROTOCOL_VERSION,
|
||
cols: (80 + (i % 40) as u16),
|
||
rows: (24 + (i % 20) as u16),
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
},
|
||
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(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<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)));
|
||
|
||
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(),
|
||
};
|
||
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: PROTOCOL_VERSION,
|
||
cols: 80,
|
||
rows: 24,
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
};
|
||
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: PROTOCOL_VERSION,
|
||
cols: 200,
|
||
rows: 60,
|
||
requested_encoding: RenderEncoding::SemanticFrame,
|
||
},
|
||
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)
|
||
}
|
||
}
|
||
}
|