1011 lines
35 KiB
Rust
1011 lines
35 KiB
Rust
//! Terminal wire types (Vterm Stage 3, protocol v19).
|
|
//!
|
|
//! A terminal is inherently a cell protocol: its identity buffer is an
|
|
//! empty CRDT anchor, so a semantic frontend that receives only
|
|
//! `BufferSnapshot` has nothing to draw. [`TerminalFrame`] carries the
|
|
//! complete visible grid instead — cells, cursor, per-view selection and
|
|
//! scroll state, and sanitized process metadata.
|
|
//!
|
|
//! Every limit needed to validate a frame lives here rather than in the
|
|
//! `pmacs` crate, so the daemon's pre-emission check and a frontend's
|
|
//! post-decode check run the SAME policy. [`TerminalFrame::validate`] is
|
|
//! that single structural policy. A second implementation of these rules
|
|
//! in a frontend is a bug, not a convenience.
|
|
//!
|
|
//! This module owns the crate's only `unicode-width` use: glyph column
|
|
//! width and wide-continuation topology cannot be checked without it.
|
|
|
|
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
|
|
use crate::ids::BufferId;
|
|
|
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared limits
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Maximum terminal rows accepted at creation, resize, or on the wire.
|
|
pub const MAX_TERMINAL_ROWS: u16 = 512;
|
|
|
|
/// Maximum terminal columns accepted at creation, resize, or on the wire.
|
|
pub const MAX_TERMINAL_COLS: u16 = 512;
|
|
|
|
/// Maximum visible terminal cells accepted at creation, resize, or on
|
|
/// the wire.
|
|
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
|
|
|
|
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
|
|
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
|
|
|
|
/// Shared cap for terminal title and process-outcome metadata.
|
|
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
|
|
|
|
/// Aggregate cap on glyph bytes carried by one [`TerminalFrame`].
|
|
///
|
|
/// The visible-cell bound alone does not bound a frame's encoded size:
|
|
/// every cell may legally carry a [`MAX_TERMINAL_GRAPHEME_BYTES`]
|
|
/// cluster, and `262_144 * 256` is 64 MiB — four times the transport's
|
|
/// [`crate::MAX_FRAME_BYTES`]. Raising the transport cap would widen the
|
|
/// allocation ceiling for every pre-handshake and established message on
|
|
/// every connection, so v19 bounds the terminal payload instead. The
|
|
/// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap`
|
|
/// measures the largest legal frame this bound admits and pins it below
|
|
/// the unchanged 16 MiB cap.
|
|
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Payload types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Process outcome published with a terminal frame.
|
|
///
|
|
/// `Signaled` and `Crashed` text is sanitized metadata subject to
|
|
/// [`MAX_TERMINAL_METADATA_BYTES`] and the control-character rule; it is
|
|
/// display text, never a host control effect.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub enum TerminalProcessState {
|
|
/// Child is running, or termination has only been requested.
|
|
Running,
|
|
/// Child exited with a status code.
|
|
Exited(i32),
|
|
/// Child was terminated by a sanitized symbolic signal.
|
|
Signaled(String),
|
|
/// Supervision failed after the session was published.
|
|
Crashed(String),
|
|
}
|
|
|
|
/// One selected span on one visible terminal row.
|
|
///
|
|
/// `start_col` is inclusive, `end_col` exclusive. A frame carries at most
|
|
/// one span per row, in strictly increasing row order.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct TerminalSelectionSpan {
|
|
/// Visible row.
|
|
pub row: u32,
|
|
/// Inclusive starting column.
|
|
pub start_col: u32,
|
|
/// Exclusive ending column.
|
|
pub end_col: u32,
|
|
}
|
|
|
|
/// A complete visible terminal grid for one frontend/window view.
|
|
///
|
|
/// This is a whole-grid replacement, never a delta. Empty is NOT a clear
|
|
/// sentinel: valid dimensions are nonzero and `cells.len()` equals the
|
|
/// checked area exactly, so a receiver that decodes a valid frame always
|
|
/// has a complete picture.
|
|
///
|
|
/// `screen_generation` describes the published screen/title generation
|
|
/// only. Selection, scroll, viewport, and process state change WITHOUT
|
|
/// advancing it, so a producer must compare the complete ordered payload
|
|
/// — not that counter — when deciding whether to send.
|
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
|
pub struct TerminalFrame {
|
|
/// Identity buffer whose session this frame projects.
|
|
pub buffer_id: BufferId,
|
|
/// Visible grid dimensions in cells.
|
|
pub size: CellSize,
|
|
/// Row-major visible cells; exactly `size.area()` entries.
|
|
pub cells: Vec<Cell>,
|
|
/// Visible child cursor, or `None` when hidden.
|
|
pub cursor: Option<CellCoord>,
|
|
/// Sanitized child title.
|
|
pub title: Option<String>,
|
|
/// Published terminal screen/title generation.
|
|
pub screen_generation: u64,
|
|
/// Per-row selection spans, strictly increasing by row.
|
|
pub selection: Vec<TerminalSelectionSpan>,
|
|
/// Physical retained rows between this viewport and the live tail.
|
|
pub scroll_offset: u32,
|
|
/// Whether this view reaches the live tail; always `scroll_offset == 0`.
|
|
pub at_bottom: bool,
|
|
/// Operating-system process id for this session generation.
|
|
pub pid: u32,
|
|
/// Latest observed process state.
|
|
pub process: TerminalProcessState,
|
|
}
|
|
|
|
/// Why a [`TerminalFrame`] is not structurally valid.
|
|
///
|
|
/// Validation is atomic: the frame is rejected whole. A receiver retains
|
|
/// its previous valid frame rather than applying any part of this one.
|
|
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
|
|
pub enum TerminalFrameError {
|
|
/// Rows or columns are zero or above the shared bounds.
|
|
#[error("terminal size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
|
|
Size {
|
|
/// Declared rows.
|
|
rows: u32,
|
|
/// Declared columns.
|
|
cols: u32,
|
|
/// Shared row bound.
|
|
max_rows: u32,
|
|
/// Shared column bound.
|
|
max_cols: u32,
|
|
},
|
|
/// The checked area exceeds the shared visible-cell bound.
|
|
#[error("terminal area {area} exceeds the visible-cell bound {max}")]
|
|
Area {
|
|
/// Checked `rows * cols`.
|
|
area: usize,
|
|
/// Shared visible-cell bound.
|
|
max: usize,
|
|
},
|
|
/// `cells.len()` disagrees with the declared area.
|
|
#[error("terminal frame carries {actual} cells for a {expected}-cell area")]
|
|
CellCount {
|
|
/// Declared area.
|
|
expected: usize,
|
|
/// Supplied cell count.
|
|
actual: usize,
|
|
},
|
|
/// The cursor lies outside the declared grid.
|
|
#[error("terminal cursor ({row},{col}) is outside the {rows}x{cols} grid")]
|
|
Cursor {
|
|
/// Cursor row.
|
|
row: u32,
|
|
/// Cursor column.
|
|
col: u32,
|
|
/// Declared rows.
|
|
rows: u32,
|
|
/// Declared columns.
|
|
cols: u32,
|
|
},
|
|
/// Title or process metadata is too long or carries control characters.
|
|
#[error("terminal {field} metadata is invalid: {reason}")]
|
|
Metadata {
|
|
/// Which metadata field failed.
|
|
field: &'static str,
|
|
/// Why it failed.
|
|
reason: &'static str,
|
|
},
|
|
/// A cell's glyph is not a legal terminal glyph.
|
|
#[error("terminal cell {index} has an invalid glyph: {reason}")]
|
|
Glyph {
|
|
/// Row-major cell index.
|
|
index: usize,
|
|
/// Why the glyph failed.
|
|
reason: &'static str,
|
|
},
|
|
/// A cell carries a frontend attachment, which terminals never use.
|
|
#[error("terminal cell {index} carries an attachment")]
|
|
Attachment {
|
|
/// Row-major cell index.
|
|
index: usize,
|
|
},
|
|
/// Aggregate glyph bytes exceed [`MAX_TERMINAL_FRAME_GLYPH_BYTES`].
|
|
#[error("terminal frame glyph bytes exceed the aggregate bound {max}")]
|
|
GlyphBudget {
|
|
/// Shared aggregate bound.
|
|
max: usize,
|
|
},
|
|
/// A selection span is empty, out of order, duplicated, or out of bounds.
|
|
#[error("terminal selection span {index} is invalid: {reason}")]
|
|
Selection {
|
|
/// Index into `selection`.
|
|
index: usize,
|
|
/// Why the span failed.
|
|
reason: &'static str,
|
|
},
|
|
/// `at_bottom` disagrees with `scroll_offset == 0`.
|
|
#[error("terminal at_bottom {at_bottom} disagrees with scroll_offset {scroll_offset}")]
|
|
BottomState {
|
|
/// Declared bottom state.
|
|
at_bottom: bool,
|
|
/// Declared scroll offset.
|
|
scroll_offset: u32,
|
|
},
|
|
}
|
|
|
|
impl TerminalFrame {
|
|
/// Check every structural rule a terminal frame must satisfy.
|
|
///
|
|
/// This is the single policy used by the daemon before emission and
|
|
/// by a frontend after decode. It is pure: a rejected frame mutates
|
|
/// nothing, so callers get atomic rejection for free.
|
|
pub fn validate(&self) -> Result<(), TerminalFrameError> {
|
|
let area = self.checked_area()?;
|
|
if self.cells.len() != area {
|
|
return Err(TerminalFrameError::CellCount {
|
|
expected: area,
|
|
actual: self.cells.len(),
|
|
});
|
|
}
|
|
if let Some(cursor) = self.cursor
|
|
&& (cursor.row >= self.size.rows || cursor.col >= self.size.cols)
|
|
{
|
|
return Err(TerminalFrameError::Cursor {
|
|
row: cursor.row,
|
|
col: cursor.col,
|
|
rows: self.size.rows,
|
|
cols: self.size.cols,
|
|
});
|
|
}
|
|
if let Some(title) = &self.title {
|
|
validate_metadata("title", title)?;
|
|
}
|
|
match &self.process {
|
|
TerminalProcessState::Signaled(text) => validate_metadata("signal", text)?,
|
|
TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
|
|
TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
|
|
}
|
|
self.validate_cells()?;
|
|
self.validate_selection()?;
|
|
if self.at_bottom != (self.scroll_offset == 0) {
|
|
return Err(TerminalFrameError::BottomState {
|
|
at_bottom: self.at_bottom,
|
|
scroll_offset: self.scroll_offset,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Declared cell area, checked against both shared bounds.
|
|
fn checked_area(&self) -> Result<usize, TerminalFrameError> {
|
|
let rows = self.size.rows;
|
|
let cols = self.size.cols;
|
|
if rows == 0
|
|
|| cols == 0
|
|
|| rows > u32::from(MAX_TERMINAL_ROWS)
|
|
|| cols > u32::from(MAX_TERMINAL_COLS)
|
|
{
|
|
return Err(TerminalFrameError::Size {
|
|
rows,
|
|
cols,
|
|
max_rows: u32::from(MAX_TERMINAL_ROWS),
|
|
max_cols: u32::from(MAX_TERMINAL_COLS),
|
|
});
|
|
}
|
|
// Both factors are bounded above by 512, so the product cannot
|
|
// overflow; `checked_mul` keeps that an assertion rather than an
|
|
// assumption a later bound change could quietly break.
|
|
let area = rows
|
|
.checked_mul(cols)
|
|
.and_then(|area| usize::try_from(area).ok())
|
|
.ok_or(TerminalFrameError::Area {
|
|
area: usize::MAX,
|
|
max: MAX_TERMINAL_VISIBLE_CELLS,
|
|
})?;
|
|
if area > MAX_TERMINAL_VISIBLE_CELLS {
|
|
return Err(TerminalFrameError::Area {
|
|
area,
|
|
max: MAX_TERMINAL_VISIBLE_CELLS,
|
|
});
|
|
}
|
|
Ok(area)
|
|
}
|
|
|
|
/// Glyph legality, wide-continuation topology, and the glyph budget.
|
|
fn validate_cells(&self) -> Result<(), TerminalFrameError> {
|
|
let cols = self.size.cols as usize;
|
|
let mut glyph_bytes = 0usize;
|
|
// Columns still owed to the preceding wide lead on this row.
|
|
let mut pending_continuation = false;
|
|
for (index, cell) in self.cells.iter().enumerate() {
|
|
if cell.attachment.is_some() {
|
|
return Err(TerminalFrameError::Attachment { index });
|
|
}
|
|
let col = index % cols;
|
|
if col == 0 && pending_continuation {
|
|
// A wide lead in the final column would have to be
|
|
// completed on the next row, which is not a footprint a
|
|
// terminal grid can express.
|
|
return Err(TerminalFrameError::Glyph {
|
|
index: index - 1,
|
|
reason: "wide glyph has no continuation column on its row",
|
|
});
|
|
}
|
|
match &cell.glyph {
|
|
Glyph::Continuation => {
|
|
if !pending_continuation {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "continuation without a preceding wide glyph",
|
|
});
|
|
}
|
|
pending_continuation = false;
|
|
}
|
|
Glyph::Char(ch) => {
|
|
if pending_continuation {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "wide glyph is not followed by its continuation",
|
|
});
|
|
}
|
|
let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "glyph is a control or zero-width character",
|
|
})?;
|
|
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?;
|
|
pending_continuation = width == 2;
|
|
}
|
|
Glyph::Cluster(bytes) => {
|
|
if pending_continuation {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "wide glyph is not followed by its continuation",
|
|
});
|
|
}
|
|
let width = cluster_display_width(bytes, index)?;
|
|
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?;
|
|
pending_continuation = width == 2;
|
|
}
|
|
}
|
|
}
|
|
if pending_continuation {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index: self.cells.len() - 1,
|
|
reason: "wide glyph has no continuation column on its row",
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// One nonempty in-bounds span per row, strictly increasing by row.
|
|
fn validate_selection(&self) -> Result<(), TerminalFrameError> {
|
|
let mut previous_row: Option<u32> = None;
|
|
for (index, span) in self.selection.iter().enumerate() {
|
|
if span.row >= self.size.rows {
|
|
return Err(TerminalFrameError::Selection {
|
|
index,
|
|
reason: "row is outside the frame",
|
|
});
|
|
}
|
|
if span.start_col >= span.end_col {
|
|
return Err(TerminalFrameError::Selection {
|
|
index,
|
|
reason: "span is empty or reversed",
|
|
});
|
|
}
|
|
if span.end_col > self.size.cols {
|
|
return Err(TerminalFrameError::Selection {
|
|
index,
|
|
reason: "span extends past the final column",
|
|
});
|
|
}
|
|
if previous_row.is_some_and(|previous| previous >= span.row) {
|
|
return Err(TerminalFrameError::Selection {
|
|
index,
|
|
reason: "rows are not strictly increasing",
|
|
});
|
|
}
|
|
previous_row = Some(span.row);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
|
|
fn char_display_width(ch: char) -> Option<usize> {
|
|
if ch.is_control() {
|
|
return None;
|
|
}
|
|
match UnicodeWidthChar::width(ch) {
|
|
Some(1) => Some(1),
|
|
Some(2) => Some(2),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Column width of a leading `Cluster` glyph.
|
|
///
|
|
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
|
|
/// when it writes the cluster: a base plus combining marks may measure
|
|
/// wider than two columns, and the screen occupies two. Clamping in one
|
|
/// place and measuring in another is how a frame that renders correctly
|
|
/// gets rejected on the wire.
|
|
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, TerminalFrameError> {
|
|
if bytes.is_empty() {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "cluster is empty",
|
|
});
|
|
}
|
|
if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "cluster exceeds the per-cluster byte limit",
|
|
});
|
|
}
|
|
let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "cluster is not valid UTF-8",
|
|
})?;
|
|
if text.chars().any(char::is_control) {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "cluster carries a control character",
|
|
});
|
|
}
|
|
let width = UnicodeWidthStr::width(text);
|
|
if width == 0 {
|
|
return Err(TerminalFrameError::Glyph {
|
|
index,
|
|
reason: "cluster occupies no columns",
|
|
});
|
|
}
|
|
Ok(width.min(2))
|
|
}
|
|
|
|
/// Accumulate glyph bytes under the aggregate bound with checked addition.
|
|
fn add_glyph_bytes(total: usize, add: usize) -> Result<usize, TerminalFrameError> {
|
|
let next = total
|
|
.checked_add(add)
|
|
.ok_or(TerminalFrameError::GlyphBudget {
|
|
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
|
})?;
|
|
if next > MAX_TERMINAL_FRAME_GLYPH_BYTES {
|
|
return Err(TerminalFrameError::GlyphBudget {
|
|
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
|
});
|
|
}
|
|
Ok(next)
|
|
}
|
|
|
|
/// Length and control-character rules shared by title and process text.
|
|
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
|
|
if text.len() > MAX_TERMINAL_METADATA_BYTES {
|
|
return Err(TerminalFrameError::Metadata {
|
|
field,
|
|
reason: "exceeds the metadata byte limit",
|
|
});
|
|
}
|
|
if text.chars().any(char::is_control) {
|
|
return Err(TerminalFrameError::Metadata {
|
|
field,
|
|
reason: "carries a control character",
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::cell::{Color, Style, UnderlineStyle};
|
|
use crate::message::InstanceMessage;
|
|
use crate::transport::MAX_FRAME_BYTES;
|
|
|
|
/// The style whose postcard encoding is as long as a legal `Style`
|
|
/// gets: three truecolor components, a non-`None` underline, and
|
|
/// every boolean set.
|
|
fn maximal_style() -> Style {
|
|
Style {
|
|
fg: Color::Rgb(0xff, 0xee, 0xdd),
|
|
bg: Color::Rgb(0x11, 0x22, 0x33),
|
|
bold: true,
|
|
italic: true,
|
|
underline: UnderlineStyle::Dashed,
|
|
reverse: true,
|
|
underline_color: Color::Rgb(0x44, 0x55, 0x66),
|
|
}
|
|
}
|
|
|
|
/// A legal one-column cluster of exactly `len` UTF-8 bytes.
|
|
///
|
|
/// A combining mark is two bytes, so parity comes from the base:
|
|
/// a one-byte space for odd lengths, a two-byte `é` for even ones.
|
|
/// Width stays 1 no matter how many marks are appended.
|
|
fn cluster_of_len(len: usize) -> Vec<u8> {
|
|
assert!((1..=MAX_TERMINAL_GRAPHEME_BYTES).contains(&len));
|
|
let mut text = String::with_capacity(len);
|
|
if len % 2 == 1 {
|
|
text.push(' ');
|
|
} else {
|
|
text.push('\u{e9}');
|
|
}
|
|
while text.len() < len {
|
|
text.push('\u{301}');
|
|
}
|
|
assert_eq!(text.len(), len);
|
|
assert_eq!(UnicodeWidthStr::width(text.as_str()), 1);
|
|
text.into_bytes()
|
|
}
|
|
|
|
fn cell_with(glyph: Glyph) -> Cell {
|
|
Cell {
|
|
glyph,
|
|
style: maximal_style(),
|
|
attachment: None,
|
|
}
|
|
}
|
|
|
|
/// A minimal valid frame: `rows * cols` single-space cells.
|
|
fn frame(rows: u32, cols: u32) -> TerminalFrame {
|
|
let area = (rows * cols) as usize;
|
|
TerminalFrame {
|
|
buffer_id: BufferId::from_raw(7),
|
|
size: CellSize::new(rows, cols),
|
|
cells: vec![cell_with(Glyph::Char(' ')); area],
|
|
cursor: Some(CellCoord::new(0, 0)),
|
|
title: Some("sh".into()),
|
|
screen_generation: 3,
|
|
selection: Vec::new(),
|
|
scroll_offset: 0,
|
|
at_bottom: true,
|
|
pid: 4321,
|
|
process: TerminalProcessState::Running,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exact_shared_boundaries_are_accepted() {
|
|
let mut f = frame(2, 4);
|
|
// A wide lead plus its continuation, a maximal-length cluster,
|
|
// and a maximal metadata string are all exactly legal.
|
|
f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
|
|
f.cells[1] = cell_with(Glyph::Continuation);
|
|
f.cells[2] = cell_with(Glyph::Cluster(
|
|
cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES).into_boxed_slice(),
|
|
));
|
|
f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES));
|
|
f.process = TerminalProcessState::Signaled("s".repeat(MAX_TERMINAL_METADATA_BYTES));
|
|
f.cursor = Some(CellCoord::new(1, 3));
|
|
f.selection = vec![
|
|
TerminalSelectionSpan {
|
|
row: 0,
|
|
start_col: 0,
|
|
end_col: 4,
|
|
},
|
|
TerminalSelectionSpan {
|
|
row: 1,
|
|
start_col: 3,
|
|
end_col: 4,
|
|
},
|
|
];
|
|
assert_eq!(f.validate(), Ok(()));
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::too_many_lines, reason = "one case per structural rule")]
|
|
fn every_structural_violation_is_rejected() {
|
|
// Each case mutates one axis of an otherwise valid frame, so a
|
|
// failure names exactly the rule that stopped enforcing.
|
|
let zero_rows = {
|
|
let mut f = frame(1, 1);
|
|
f.size = CellSize::new(0, 1);
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
zero_rows.validate(),
|
|
Err(TerminalFrameError::Size { .. })
|
|
));
|
|
|
|
let over_rows = {
|
|
let mut f = frame(1, 1);
|
|
f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS) + 1, 1);
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
over_rows.validate(),
|
|
Err(TerminalFrameError::Size { .. })
|
|
));
|
|
|
|
let over_area = {
|
|
let mut f = frame(1, 1);
|
|
f.size = CellSize::new(u32::from(MAX_TERMINAL_ROWS), u32::from(MAX_TERMINAL_COLS));
|
|
f
|
|
};
|
|
// 512 x 512 is exactly the visible-cell bound, so this frame
|
|
// fails on its cell count, not its area — the area bound is
|
|
// reached only if a later limit change makes them disagree.
|
|
assert!(matches!(
|
|
over_area.validate(),
|
|
Err(TerminalFrameError::CellCount { .. })
|
|
));
|
|
|
|
let bad_count = {
|
|
let mut f = frame(2, 2);
|
|
f.cells.pop();
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
bad_count.validate(),
|
|
Err(TerminalFrameError::CellCount {
|
|
expected: 4,
|
|
actual: 3
|
|
})
|
|
));
|
|
|
|
let bad_cursor = {
|
|
let mut f = frame(2, 2);
|
|
f.cursor = Some(CellCoord::new(2, 0));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
bad_cursor.validate(),
|
|
Err(TerminalFrameError::Cursor { .. })
|
|
));
|
|
|
|
let long_title = {
|
|
let mut f = frame(1, 1);
|
|
f.title = Some("t".repeat(MAX_TERMINAL_METADATA_BYTES + 1));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
long_title.validate(),
|
|
Err(TerminalFrameError::Metadata { field: "title", .. })
|
|
));
|
|
|
|
let control_title = {
|
|
let mut f = frame(1, 1);
|
|
f.title = Some("ok\u{7}".into());
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
control_title.validate(),
|
|
Err(TerminalFrameError::Metadata { field: "title", .. })
|
|
));
|
|
|
|
let long_process = {
|
|
let mut f = frame(1, 1);
|
|
f.process = TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES + 1));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
long_process.validate(),
|
|
Err(TerminalFrameError::Metadata { field: "crash", .. })
|
|
));
|
|
|
|
let control_glyph = {
|
|
let mut f = frame(1, 1);
|
|
f.cells[0] = cell_with(Glyph::Char('\u{7}'));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
control_glyph.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 0, .. })
|
|
));
|
|
|
|
let malformed_cluster = {
|
|
let mut f = frame(1, 1);
|
|
f.cells[0] = cell_with(Glyph::Cluster(vec![0xff, 0xfe].into_boxed_slice()));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
malformed_cluster.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 0, .. })
|
|
));
|
|
|
|
let empty_cluster = {
|
|
let mut f = frame(1, 1);
|
|
f.cells[0] = cell_with(Glyph::Cluster(Vec::new().into_boxed_slice()));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
empty_cluster.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 0, .. })
|
|
));
|
|
|
|
let overlong_cluster = {
|
|
let mut f = frame(1, 1);
|
|
let mut bytes = cluster_of_len(MAX_TERMINAL_GRAPHEME_BYTES);
|
|
bytes.extend_from_slice("\u{301}".as_bytes());
|
|
f.cells[0] = cell_with(Glyph::Cluster(bytes.into_boxed_slice()));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
overlong_cluster.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 0, .. })
|
|
));
|
|
|
|
let orphan_continuation = {
|
|
let mut f = frame(1, 2);
|
|
f.cells[1] = cell_with(Glyph::Continuation);
|
|
f.cells[0] = cell_with(Glyph::Char('a'));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
orphan_continuation.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 1, .. })
|
|
));
|
|
|
|
let missing_continuation = {
|
|
let mut f = frame(1, 2);
|
|
f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
missing_continuation.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 1, .. })
|
|
));
|
|
|
|
// A wide lead in the final column has nowhere to put its
|
|
// continuation: the next cell belongs to the next row.
|
|
let wide_at_row_end = {
|
|
let mut f = frame(2, 2);
|
|
f.cells[1] = cell_with(Glyph::Char('\u{4e00}'));
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
wide_at_row_end.validate(),
|
|
Err(TerminalFrameError::Glyph { index: 1, .. })
|
|
));
|
|
|
|
let attachment = {
|
|
let mut f = frame(1, 1);
|
|
f.cells[0].attachment = Some(crate::cell::Attachment::ImageCell {
|
|
image_id: 1,
|
|
sub_x: 0,
|
|
sub_y: 0,
|
|
});
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
attachment.validate(),
|
|
Err(TerminalFrameError::Attachment { index: 0 })
|
|
));
|
|
|
|
let reversed_span = {
|
|
let mut f = frame(1, 4);
|
|
f.selection = vec![TerminalSelectionSpan {
|
|
row: 0,
|
|
start_col: 3,
|
|
end_col: 1,
|
|
}];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
reversed_span.validate(),
|
|
Err(TerminalFrameError::Selection { index: 0, .. })
|
|
));
|
|
|
|
let empty_span = {
|
|
let mut f = frame(1, 4);
|
|
f.selection = vec![TerminalSelectionSpan {
|
|
row: 0,
|
|
start_col: 2,
|
|
end_col: 2,
|
|
}];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
empty_span.validate(),
|
|
Err(TerminalFrameError::Selection { index: 0, .. })
|
|
));
|
|
|
|
let duplicate_row = {
|
|
let mut f = frame(2, 4);
|
|
f.selection = vec![
|
|
TerminalSelectionSpan {
|
|
row: 1,
|
|
start_col: 0,
|
|
end_col: 1,
|
|
},
|
|
TerminalSelectionSpan {
|
|
row: 1,
|
|
start_col: 1,
|
|
end_col: 2,
|
|
},
|
|
];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
duplicate_row.validate(),
|
|
Err(TerminalFrameError::Selection { index: 1, .. })
|
|
));
|
|
|
|
let out_of_order = {
|
|
let mut f = frame(2, 4);
|
|
f.selection = vec![
|
|
TerminalSelectionSpan {
|
|
row: 1,
|
|
start_col: 0,
|
|
end_col: 1,
|
|
},
|
|
TerminalSelectionSpan {
|
|
row: 0,
|
|
start_col: 0,
|
|
end_col: 1,
|
|
},
|
|
];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
out_of_order.validate(),
|
|
Err(TerminalFrameError::Selection { index: 1, .. })
|
|
));
|
|
|
|
let span_past_end = {
|
|
let mut f = frame(1, 4);
|
|
f.selection = vec![TerminalSelectionSpan {
|
|
row: 0,
|
|
start_col: 0,
|
|
end_col: 5,
|
|
}];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
span_past_end.validate(),
|
|
Err(TerminalFrameError::Selection { index: 0, .. })
|
|
));
|
|
|
|
let span_off_frame = {
|
|
let mut f = frame(1, 4);
|
|
f.selection = vec![TerminalSelectionSpan {
|
|
row: 1,
|
|
start_col: 0,
|
|
end_col: 1,
|
|
}];
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
span_off_frame.validate(),
|
|
Err(TerminalFrameError::Selection { index: 0, .. })
|
|
));
|
|
|
|
let inconsistent_bottom = {
|
|
let mut f = frame(1, 1);
|
|
f.scroll_offset = 4;
|
|
f.at_bottom = true;
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
inconsistent_bottom.validate(),
|
|
Err(TerminalFrameError::BottomState { .. })
|
|
));
|
|
|
|
let inconsistent_top = {
|
|
let mut f = frame(1, 1);
|
|
f.scroll_offset = 0;
|
|
f.at_bottom = false;
|
|
f
|
|
};
|
|
assert!(matches!(
|
|
inconsistent_top.validate(),
|
|
Err(TerminalFrameError::BottomState { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn aggregate_glyph_budget_accepts_the_exact_bound_and_rejects_one_byte_over() {
|
|
// Reaching the aggregate bound exactly requires the whole legal
|
|
// grid — no smaller frame can spend 8 MiB of glyph — so the
|
|
// boundary pair is built once and asserted in both directions.
|
|
let (exact, over) = budget_boundary_frames();
|
|
assert_eq!(exact.validate(), Ok(()));
|
|
assert_eq!(
|
|
over.validate(),
|
|
Err(TerminalFrameError::GlyphBudget {
|
|
max: MAX_TERMINAL_FRAME_GLYPH_BYTES
|
|
})
|
|
);
|
|
}
|
|
|
|
/// The exact-budget frame and its one-byte-over twin.
|
|
///
|
|
/// Cluster lengths are chosen to maximize serialized length-prefix
|
|
/// overhead: postcard spends two bytes on any length `>= 128`, so as
|
|
/// many cells as the budget allows carry a 128-byte cluster and the
|
|
/// rest carry the one-byte minimum, rather than packing the budget
|
|
/// into a few maximal clusters that would each cost one prefix.
|
|
fn budget_boundary_frames() -> (TerminalFrame, TerminalFrame) {
|
|
/// Shortest cluster length postcard encodes with a two-byte
|
|
/// length prefix.
|
|
const WIDE_PREFIX_LEN: usize = 128;
|
|
let rows = u32::from(MAX_TERMINAL_ROWS);
|
|
let cols = u32::from(MAX_TERMINAL_COLS);
|
|
let area = (rows * cols) as usize;
|
|
assert_eq!(area, MAX_TERMINAL_VISIBLE_CELLS);
|
|
|
|
let spare = MAX_TERMINAL_FRAME_GLYPH_BYTES - area;
|
|
let wide_cells = spare / (WIDE_PREFIX_LEN - 1);
|
|
let remainder = spare % (WIDE_PREFIX_LEN - 1);
|
|
assert!(wide_cells + usize::from(remainder > 0) <= area);
|
|
|
|
let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice();
|
|
let single = cluster_of_len(1).into_boxed_slice();
|
|
let mut cells = Vec::with_capacity(area);
|
|
for index in 0..area {
|
|
let glyph = if index < wide_cells {
|
|
Glyph::Cluster(wide.clone())
|
|
} else if index == wide_cells && remainder > 0 {
|
|
Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice())
|
|
} else {
|
|
Glyph::Cluster(single.clone())
|
|
};
|
|
cells.push(cell_with(glyph));
|
|
}
|
|
|
|
let selection = (0..rows)
|
|
.map(|row| TerminalSelectionSpan {
|
|
row,
|
|
start_col: 0,
|
|
end_col: cols,
|
|
})
|
|
.collect();
|
|
|
|
let exact = TerminalFrame {
|
|
buffer_id: BufferId::from_raw(u64::MAX),
|
|
size: CellSize::new(rows, cols),
|
|
cells,
|
|
cursor: Some(CellCoord::new(rows - 1, cols - 1)),
|
|
title: Some("t".repeat(MAX_TERMINAL_METADATA_BYTES)),
|
|
screen_generation: u64::MAX,
|
|
selection,
|
|
scroll_offset: u32::MAX,
|
|
at_bottom: false,
|
|
pid: u32::MAX,
|
|
process: TerminalProcessState::Crashed("c".repeat(MAX_TERMINAL_METADATA_BYTES)),
|
|
};
|
|
|
|
let mut over = exact.clone();
|
|
// One more byte of glyph, nothing else changed.
|
|
let last = over.cells.len() - 1;
|
|
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice()));
|
|
|
|
(exact, over)
|
|
}
|
|
|
|
#[test]
|
|
fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() {
|
|
let (exact, _) = budget_boundary_frames();
|
|
assert_eq!(exact.validate(), Ok(()));
|
|
|
|
let mut glyph_bytes = 0usize;
|
|
for cell in &exact.cells {
|
|
glyph_bytes += match &cell.glyph {
|
|
Glyph::Char(ch) => ch.len_utf8(),
|
|
Glyph::Cluster(bytes) => bytes.len(),
|
|
Glyph::Continuation => 0,
|
|
};
|
|
}
|
|
assert_eq!(
|
|
glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES,
|
|
"the measured fixture must spend the whole aggregate budget"
|
|
);
|
|
|
|
let msg = InstanceMessage::TerminalFrame(exact);
|
|
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
|
assert!(
|
|
bytes.len() < MAX_FRAME_BYTES,
|
|
"largest legal terminal frame encodes to {} bytes, at or above the \
|
|
{MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \
|
|
longer keeps terminal traffic inside the existing transport limit",
|
|
bytes.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn frames_round_trip_through_postcard() {
|
|
let mut f = frame(3, 5);
|
|
f.cells[0] = cell_with(Glyph::Char('\u{4e00}'));
|
|
f.cells[1] = cell_with(Glyph::Continuation);
|
|
f.cells[2] = cell_with(Glyph::Cluster(cluster_of_len(9).into_boxed_slice()));
|
|
f.selection = vec![TerminalSelectionSpan {
|
|
row: 2,
|
|
start_col: 1,
|
|
end_col: 4,
|
|
}];
|
|
f.scroll_offset = 6;
|
|
f.at_bottom = false;
|
|
f.process = TerminalProcessState::Exited(-1);
|
|
assert_eq!(f.validate(), Ok(()));
|
|
|
|
let msg = InstanceMessage::TerminalFrame(f);
|
|
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
|
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
|
assert_eq!(decoded, msg);
|
|
}
|
|
}
|