justerm_core/serialize.rs
1//! Issue #6 — binary, reference-based wire format for a damage frame.
2//!
3//! `encode` a [`Frame`] to bytes, `decode` them back; the round-trip is the
4//! contract. Reference-based (colour refs, Unicode scalars — never resolved RGB
5//! or atlas ids) so the engine stays theme- and font-agnostic; the consumer's
6//! adapter resolves references before handing cells to the renderer. Format spec
7//! and rationale: `docs/architecture.md` §Serialization + ADR-0005.
8
9use crate::cell::{Cell, CellFlags};
10use crate::color::Color;
11use crate::cursor::CursorShape;
12use crate::damage::ScrollOp;
13use crate::input::MouseEvents;
14use crate::selection::SelectionSpan;
15use core::num::NonZeroU32;
16use std::collections::BTreeMap;
17
18/// Wire magic ("juSTerm") + format version. A new feature bumps `VERSION`.
19const MAGIC: [u8; 2] = *b"JT";
20const VERSION: u8 = 14; // v14 moves combining clusters and hyperlink refs off the fixed cell record (18 B -> 14 B) into per-span sparse groups, inlining the cluster (no side-table) but keeping the URI table interned, and widens every count/length prefix they use to u32 — the engine could hold a cluster, a URI, or a viewport its own decoder then rejected or, worse, mis-read as Ok (#621); v13 adds a per-span underline-colour group: sparse (col, Color) pairs for cells drawing a coloured underline (SGR 58, #520); v12 adds a fifth overlay group: the consumer-designated active search match's spans (#428); v11 adds a fourth overlay group: every live marker's absolute buffer line for the overview ruler (#120 S3); v10 adds a marker kind discriminant + optional i32 exit to the overlay marker group (#159); v9 adds the alt-screen flag in the header (#149); v8 adds the mouse wanted-events mask in the header (#129/ADR-0016); v7 overlay marker group (#118/ADR-0015); v6 overlay selection + search-match spans (#108/ADR-0014); v5 scroll position (#112/ADR-0013); v4 cursor shape+blink (#81); v3 cursor row/col/visibility (#38)
21
22/// The wire-format version (the gating `VERSION` byte), exposed so a binding can
23/// assert at load that its decoder matches the backend encoder (#34/ADR-0008).
24pub const WIRE_VERSION: u8 = VERSION;
25
26/// Whether a frame redraws everything or just its spans.
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub enum FrameKind {
29 /// Every row is present (resize / alt-screen clear).
30 Full,
31 /// Only the listed spans changed since the consumer's ack.
32 Partial,
33}
34
35/// A damaged column run on one line, with its cells.
36///
37/// `combining` and `links` map a span-relative column to what that cell carries —
38/// combining clusters (#45) and hyperlinks (#46) live in per-row maps, so neither
39/// rides the cell. Since v14 (#621) both are **sparse wire groups of their own**,
40/// not indices in the cell record, which is what removed the `u16` ceilings the
41/// engine could legitimately exceed.
42///
43/// The two are deliberately **not** symmetric, and the asymmetry is measured rather
44/// than stylistic:
45///
46/// - `combining` holds the cluster **inline**. `Term::frame` pushes one entry per
47/// combining cell with no interning, so an index bought nothing but a level of
48/// indirection and a table to count. Inlining is size-neutral (measured: −0.5% on
49/// a combining-heavy frame) and buys the deletion of both.
50/// - `links` holds a **1-based index into [`Frame::link_table`]**, because that
51/// table *is* interned (`Term::frame`'s `link_remap` ships each referenced URI
52/// once). Inlining a URI at every linked cell was measured at +171…403% on
53/// link-dense frames, and all three references share one copy across cells —
54/// ghostty ref-counts its hyperlink set explicitly *"so that a set of cells can
55/// share the same hyperlink without duplicating the data"*, xterm.js keys cells to
56/// an `OscLinkService` id, alacritty holds `Arc<HyperlinkInner>`.
57///
58/// A column is present in either map iff its cell carries the matching bit — and,
59/// as with `ucolors` below, that bit does **not** travel on the wire (the record
60/// encodes `cell.c()` and `encode_color(bg)`, which drop `C_COMBINED` and
61/// `LINK_PRESENT` respectively). `decode` re-arms both from these maps' own entries.
62/// A `Span` built by hand for a test owes the same pairing.
63#[derive(Clone, PartialEq, Eq, Debug)]
64pub struct Span {
65 pub line: u16,
66 pub left: u16,
67 pub right: u16,
68 pub cells: Vec<Cell>,
69 pub combining: BTreeMap<usize, Vec<char>>,
70 pub links: BTreeMap<usize, NonZeroU32>,
71 /// Underline colours (SGR 58, #520): span-relative column → the `Color`
72 /// reference the cell's coloured underline draws in. Sparse — only cells that
73 /// carry a non-default underline colour appear (gated on the `UNDERLINE`
74 /// attribute at parse time). Unlike `combining`/`links` this is a colour
75 /// reference, not a side-table index, so it ships inline (no `_table` on the
76 /// [`Frame`]). Kept off the per-cell record so a plain-text frame pays nothing
77 /// (ADR-0020: no inert per-cell payload).
78 ///
79 /// Like `combining` and `links`, a column here is present iff its cell carries
80 /// the matching bit ([`Cell::is_ucolored`]) — but that bit does **not** travel on
81 /// the wire (`encode_color` keeps only mode+value, and `CellFlags` holds no
82 /// presence bits), so `decode` re-arms it from this map's own entries. A `Span`
83 /// built by hand for a test owes the same pairing: an entry here without
84 /// [`Cell::set_ucolored`] on the cell is a column the gated readers cannot see.
85 /// (#531)
86 pub ucolors: BTreeMap<usize, Color>,
87}
88
89/// A stable handle to a buffer line, handed out by `Engine::add_marker` (#118).
90/// Monotonic per engine. The consumer attaches a decoration to the id; the frame
91/// reports where the marker currently sits, and `TermEvent::MarkerDisposed`
92/// signals when its line has left the buffer.
93#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
94pub struct MarkerId(pub u32);
95
96/// What a marker means (#158). A plain `add_marker` decoration carries no
97/// semantics ([`MarkerKind::Plain`]); OSC 133 shell-integration marks carry the
98/// command-boundary role (prompt/command/output start, or command finished with
99/// its optional exit code). The engine only *parses and anchors* these — the
100/// success/failure colour, earcon and prompt-to-prompt navigation are consumer
101/// policy (ADR-0017), driven off the kind + exit the wire (#159) carries.
102#[derive(Clone, Copy, PartialEq, Eq, Debug)]
103pub enum MarkerKind {
104 /// A `add_marker` decoration anchor (#118) — no OSC-133 semantics.
105 Plain,
106 /// OSC `133;A` — the shell prompt begins here.
107 PromptStart,
108 /// OSC `133;B` — the typed command begins here (the prompt ended).
109 CommandStart,
110 /// OSC `133;C` — the command was submitted; its output begins here.
111 OutputStart,
112 /// OSC `133;D[;exit]` — the command finished, with its exit code if reported
113 /// (absent, empty or non-numeric → `None`).
114 CommandFinished(Option<i32>),
115}
116
117/// A marker projected onto the viewport (#118): its id, the row it sits on, and
118/// its kind (#159). Only markers visible in the current viewport are reported; an
119/// off-screen marker is omitted but still alive (death comes via `MarkerDisposed`,
120/// not absence — so the consumer can tell "scrolled away" from "gone"). The kind
121/// carries the OSC 133 command-boundary role + exit code so the consumer can drive
122/// prompt-to-prompt navigation and success/fail signals (#160).
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124pub struct MarkerPosition {
125 pub id: MarkerId,
126 pub row: usize,
127 pub kind: MarkerKind,
128}
129
130/// A marker's absolute buffer line (#120 S3, v11). Unlike [`MarkerPosition`],
131/// this is reported for EVERY live marker — on-screen or not — so a frame-mode
132/// consumer can place overview-ruler marks buffer-relatively (dividing by
133/// `scrollback + rows`), the whole point of a ruler being to show off-viewport
134/// anchors. The consumer joins `id` with its decoration registry; the ruler mark's
135/// colour is the consumer's (theme-agnostic), so no kind/exit rides here.
136#[derive(Clone, Copy, PartialEq, Eq, Debug)]
137pub struct MarkerLine {
138 pub id: MarkerId,
139 /// Absolute buffer line, in the same `[0, scrollback_len + rows)` frame the
140 /// header's `scrollback_len`/`display_offset` use.
141 pub line: u32,
142}
143
144/// Interaction overlays projected onto the viewport (#108): highlight spans the
145/// engine carries on the frame so a frame-mode consumer can paint them without
146/// an in-process model query. Positions only — highlight colour is the
147/// consumer's (theme-agnostic). Coordinates are viewport rows/cols, re-projected
148/// by `frame()` against the scroll offset so the engine stays the single
149/// anchoring authority.
150#[derive(Clone, PartialEq, Eq, Debug, Default)]
151pub struct Overlay {
152 /// The live selection projected onto visible rows (`selection_range`).
153 pub selection: Vec<SelectionSpan>,
154 /// The search highlights projected onto visible rows. Search matches
155 /// are consumer-owned (next/prev navigation holds the `Vec<Match>`), so the
156 /// consumer hands the highlight set back via `set_search_highlights` and the
157 /// engine projects it here — mirroring how the engine-owned selection rides.
158 pub matches: Vec<SelectionSpan>,
159 /// Engine-owned markers visible in this viewport (#118): persistent line
160 /// anchors for decorations. Unlike the selection (cleared on a screen swap)
161 /// and search highlights (invalidated on output), markers re-anchor through
162 /// buffer mutation and survive an alt-screen excursion; only their viewport
163 /// position rides here.
164 pub markers: Vec<MarkerPosition>,
165 /// Every live marker's absolute buffer line (#120 S3, v11), on-screen or not —
166 /// the overview ruler needs off-viewport anchors, which `markers` (viewport-
167 /// only) can't supply. A superset of `markers` by id; different frame of
168 /// reference (absolute line, not viewport row).
169 pub marker_lines: Vec<MarkerLine>,
170 /// The *active* (current) search match's spans (#428, v12): the member of
171 /// `matches` the consumer designated via `set_active_search_highlight`
172 /// (which match is active is consumer policy — next/prev navigation).
173 /// Projected by the same mechanism as `matches`, and *also* present there —
174 /// the renderer's highlight ranking resolves the overlap (#424), not
175 /// exclusion here. Empty when nothing is designated.
176 pub active_match: Vec<SelectionSpan>,
177}
178
179/// One serialized damage cycle: the decoded logical form that `encode`/`decode`
180/// round-trip. `link_table` holds this frame's OSC 8 hyperlink URIs, each shipped
181/// once and referenced by [`Span::links`]. Grapheme clusters have **no** table —
182/// since v14 (#621) they are inlined at their column in [`Span::combining`],
183/// because nothing interned them and the table only bought an index to overflow.
184#[derive(Clone, PartialEq, Eq, Debug)]
185pub struct Frame {
186 pub cols: u16,
187 pub rows: u16,
188 pub kind: FrameKind,
189 /// Cursor row/col in screen coordinates (0-based), and whether the engine
190 /// shows it (DECTCEM). Rides in the header because the cursor moves with
191 /// almost every frame (#38). *Drawing* the cursor — cell-invert / overlay —
192 /// stays the consumer's renderer adapter; the engine only reports state.
193 pub cursor_row: u16,
194 pub cursor_col: u16,
195 pub cursor_visible: bool,
196 /// The caret shape (DECSCUSR #89) and whether it blinks (att610 ?12, #81).
197 /// Reported for the renderer; drawing/animation stays the consumer's.
198 pub cursor_shape: CursorShape,
199 pub cursor_blink: bool,
200 /// Viewport scroll position (#112 / ADR-0013), for the consumer's scrollbar.
201 /// `display_offset` = lines scrolled up from the bottom (0 = following the
202 /// live screen); `scrollback_len` = history lines (total = `+ rows`). Ride in
203 /// the header like the cursor — per-frame viewport state, not cell content.
204 pub display_offset: u32,
205 pub scrollback_len: u32,
206 /// The mouse tracking mode as a *wanted-events* mask (#129): which mouse
207 /// event categories the app asked to receive, so the consumer routes an event
208 /// to the app (bit set) or keeps it local. `empty()` = no reporting. Rides the
209 /// header like the cursor — per-frame mode state the consumer reads, not cell
210 /// content. Positions/encoding never cross; the backend encodes via
211 /// `encode_mouse`.
212 pub mouse_events: MouseEvents,
213 /// Whether the alternate screen (`?1049`/`?47`) is active (#149). Buffer-global
214 /// state a frame-mode consumer can't derive from viewport damage — the
215 /// accessibility announce policy (#119) gates on it (suppress TUI repaints).
216 /// Rides the header like the cursor scalars (ADR-0014).
217 pub alt_screen: bool,
218 pub scroll: Option<ScrollOp>,
219 pub spans: Vec<Span>,
220 pub link_table: Vec<String>,
221 /// Interaction overlays for this viewport (#108): selection, search
222 /// highlights, the active match, and markers — see [`Overlay`].
223 pub overlay: Overlay,
224}
225
226/// Why a byte buffer could not be decoded into a [`Frame`].
227#[derive(Clone, Copy, PartialEq, Eq, Debug)]
228pub enum DecodeError {
229 /// Ran out of bytes mid-field.
230 Truncated,
231 /// First two bytes are not the wire magic.
232 BadMagic,
233 /// Unsupported format version.
234 BadVersion(u8),
235 /// A tag/kind byte held a value outside its defined set.
236 BadTag,
237 /// A span's `left` was past its `right` (would underflow the cell count).
238 BadSpan,
239}
240
241/// Serialize a frame to the binary wire format.
242pub fn encode(frame: &Frame) -> Vec<u8> {
243 let mut out = Vec::new();
244 out.extend_from_slice(&MAGIC);
245 out.push(VERSION);
246 out.push(frame.scroll.is_some() as u8);
247 out.push(match frame.kind {
248 FrameKind::Full => 0,
249 FrameKind::Partial => 1,
250 });
251 out.extend_from_slice(&frame.cols.to_le_bytes());
252 out.extend_from_slice(&frame.rows.to_le_bytes());
253 out.extend_from_slice(&frame.cursor_row.to_le_bytes());
254 out.extend_from_slice(&frame.cursor_col.to_le_bytes());
255 out.push(frame.cursor_visible as u8);
256 out.push(match frame.cursor_shape {
257 CursorShape::Block => 0,
258 CursorShape::Underline => 1,
259 CursorShape::Bar => 2,
260 });
261 out.push(frame.cursor_blink as u8);
262 out.extend_from_slice(&frame.display_offset.to_le_bytes());
263 out.extend_from_slice(&frame.scrollback_len.to_le_bytes());
264 // Mouse wanted-events mask (#129): one byte in the header, like the cursor
265 // scalars. Off = 0.
266 out.push(frame.mouse_events.bits());
267 // Alt-screen flag (#149): one byte in the header, like the cursor scalars.
268 out.push(frame.alt_screen as u8);
269 if let Some(s) = frame.scroll {
270 out.extend_from_slice(&(s.top as u16).to_le_bytes());
271 out.extend_from_slice(&(s.bottom as u16).to_le_bytes());
272 out.extend_from_slice(&(s.count as i16).to_le_bytes());
273 }
274 out.extend_from_slice(&(frame.spans.len() as u16).to_le_bytes());
275 for span in &frame.spans {
276 out.extend_from_slice(&span.line.to_le_bytes());
277 out.extend_from_slice(&span.left.to_le_bytes());
278 out.extend_from_slice(&span.right.to_le_bytes());
279 for cell in &span.cells {
280 out.extend_from_slice(&encode_cell_record(cell));
281 }
282 }
283 // Hyperlink table (#26), interned: each referenced URI ships once as a
284 // length-prefixed UTF-8 run, and `Span::links` points at it. Both the count and
285 // the length are u32 since v14 — the old u16 length rejected a URI the engine
286 // stores happily, and the old u16 count could not describe one entry per cell of
287 // a viewport the header's own `cols`/`rows` (u16 *each*) permit (#621).
288 out.extend_from_slice(&(frame.link_table.len() as u32).to_le_bytes());
289 for uri in &frame.link_table {
290 out.extend_from_slice(&(uri.len() as u32).to_le_bytes());
291 out.extend_from_slice(uri.as_bytes());
292 }
293 // Combining-cluster group (#45, v14): one sparse map per span, in span order, so
294 // the column keys need no span index — the same positional convention `ucolors`
295 // uses below. Each entry is `(col u16, len u32, len * char u32)`, the cluster
296 // inline. There is no side-table and no index: nothing interned them, so the
297 // indirection only bought a second count to overflow (#621).
298 for span in &frame.spans {
299 out.extend_from_slice(&(span.combining.len() as u32).to_le_bytes());
300 for (&col, cluster) in &span.combining {
301 out.extend_from_slice(&(col as u16).to_le_bytes());
302 out.extend_from_slice(&(cluster.len() as u32).to_le_bytes());
303 for &ch in cluster {
304 out.extend_from_slice(&(ch as u32).to_le_bytes());
305 }
306 }
307 }
308 // Hyperlink reference group (#46, v14): same positional shape, but the value is a
309 // 1-based index into `link_table` rather than the URI — see `Span`'s doc for why
310 // this half stays interned where the one above does not.
311 for span in &frame.spans {
312 out.extend_from_slice(&(span.links.len() as u32).to_le_bytes());
313 for (&col, &idx) in &span.links {
314 out.extend_from_slice(&(col as u16).to_le_bytes());
315 out.extend_from_slice(&idx.get().to_le_bytes());
316 }
317 }
318 // Underline-colour group (SGR 58, #520, v13): one sparse map per span, in span
319 // order, so the column keys need no span index — the decoder reads exactly
320 // `span_count` maps and attaches each to its span. Each entry is `(col u16,
321 // colour u32)`, the colour packed by the same `encode_color` as fg/bg. A frame
322 // with no coloured underlines pays 2 bytes per span (the zero count).
323 for span in &frame.spans {
324 out.extend_from_slice(&(span.ucolors.len() as u16).to_le_bytes());
325 for (&col, &color) in &span.ucolors {
326 out.extend_from_slice(&(col as u16).to_le_bytes());
327 out.extend_from_slice(&encode_color(color).to_le_bytes());
328 }
329 }
330 // Overlay section (#108): each group is a u16 count then that many
331 // `(row, left, right)` u16 viewport triples. Selection first, then search
332 // matches. Append-only, version-gated — a future group (markers, #118) adds
333 // a third count here at the next version bump.
334 encode_overlay_spans(&mut out, &frame.overlay.selection);
335 encode_overlay_spans(&mut out, &frame.overlay.matches);
336 // Third overlay group (#118): markers as `(id u32, row u16)` pairs — a
337 // different record shape from the span groups (a marker is a line anchor,
338 // not a column run). v10 (#159) appends a kind discriminant (u8, like
339 // `cursor_shape`), and — only for `CommandFinished` — a presence byte + i32
340 // exit code (the presence pattern mirrors the header's `scroll` option).
341 out.extend_from_slice(&(frame.overlay.markers.len() as u16).to_le_bytes());
342 for m in &frame.overlay.markers {
343 out.extend_from_slice(&m.id.0.to_le_bytes());
344 out.extend_from_slice(&(m.row as u16).to_le_bytes());
345 out.push(match m.kind {
346 MarkerKind::Plain => 0,
347 MarkerKind::PromptStart => 1,
348 MarkerKind::CommandStart => 2,
349 MarkerKind::OutputStart => 3,
350 MarkerKind::CommandFinished(_) => 4,
351 });
352 if let MarkerKind::CommandFinished(exit) = m.kind {
353 out.push(exit.is_some() as u8);
354 out.extend_from_slice(&exit.unwrap_or(0).to_le_bytes());
355 }
356 }
357 // Fourth overlay group (#120 S3, v11): every live marker's absolute buffer
358 // line as `(id u32, line u32)` pairs — a superset of the viewport marker group
359 // above, for placing overview-ruler marks off-viewport. Count-prefixed like the
360 // others.
361 out.extend_from_slice(&(frame.overlay.marker_lines.len() as u16).to_le_bytes());
362 for m in &frame.overlay.marker_lines {
363 out.extend_from_slice(&m.id.0.to_le_bytes());
364 out.extend_from_slice(&m.line.to_le_bytes());
365 }
366 // Fifth overlay group (#428, v12): the active search match's spans, same
367 // count + `(row, left, right)` shape as the selection/match groups. Appended
368 // at the tail so the section stays append-only.
369 encode_overlay_spans(&mut out, &frame.overlay.active_match);
370 out
371}
372
373/// Encode one overlay group: a u32 span count, then each span as three u16s
374/// (`row`, `left`, `right`) in viewport coordinates.
375///
376/// The count is u32 since v14 (#621), and this is the *same* defect as the cluster
377/// and URI fields — found by that issue's own acceptance item ("do not assume those
378/// two are the only `as u16` narrowings"), which the first sweep answered for the
379/// `(row, left, right)` triples and not for the count above them. A one-character
380/// search over a large viewport reaches it: measured at 1000×133, 66 000 highlight
381/// spans wrapped the count to 464, and `decode` returned **`Ok`** having also
382/// fabricated 928 marker-lines and 3 active-match spans the engine never had — the
383/// wrapped count leaves the reader mid-group, and every group after it is read from
384/// the wrong offset.
385///
386/// **This widens the three viewport-projected groups and deliberately not the two
387/// marker groups**, which keep their `u16` counts a few lines below. That is not an
388/// oversight and not inconsistency: `frame()` clips selection / matches /
389/// active-match to the viewport, so their counts are `O(viewport)` — ADR-0020 R3
390/// satisfied, and widening entrenches nothing. The marker groups report **every
391/// live marker**, on-screen or not; they are unbounded by the viewport and are the
392/// one R3 violation ADR-0020 records against itself. Widening those would make that
393/// violation cheaper to keep, which is precisely what #490 exists to remove — so
394/// they stay narrow and stay #490's.
395fn encode_overlay_spans(out: &mut Vec<u8>, spans: &[SelectionSpan]) {
396 out.extend_from_slice(&(spans.len() as u32).to_le_bytes());
397 for s in spans {
398 out.extend_from_slice(&(s.row as u16).to_le_bytes());
399 out.extend_from_slice(&(s.left as u16).to_le_bytes());
400 out.extend_from_slice(&(s.right as u16).to_le_bytes());
401 }
402}
403
404/// Length in bytes of one fixed-width wire cell record (see
405/// [`encode_cell_record`]).
406pub const CELL_RECORD_LEN: usize = 14;
407
408/// Encode one [`Cell`] to its fixed 14-byte little-endian record:
409/// `c` u32 (Unicode scalar) · `fg` u32 · `bg` u32 · `flags` u16. Width derives
410/// from `flags`.
411///
412/// **The record carries no grapheme or hyperlink reference (v14, #621).** Both were
413/// `u16` fields on every cell, and widening them to hold what the engine can
414/// legitimately store would have inflated a record every cell pays — the trade
415/// ADR-0008's Axis 4 already rejected in the other direction. They moved to sparse
416/// per-[`Span`] groups instead, which is why this record *shrank* by 4 bytes:
417/// measured, −20.9% on an ordinary frame that carries neither.
418///
419/// This is the single definition of the cell record layout — [`encode`] writes
420/// it per span cell, and an alternate consumer (the WASM decoder, #34/ADR-0008)
421/// reuses it to lay decoded cells out flat without re-implementing the layout,
422/// so the two cannot drift.
423pub fn encode_cell_record(cell: &Cell) -> [u8; CELL_RECORD_LEN] {
424 let mut r = [0u8; CELL_RECORD_LEN];
425 r[0..4].copy_from_slice(&(cell.c() as u32).to_le_bytes());
426 r[4..8].copy_from_slice(&encode_color(cell.fg()).to_le_bytes());
427 r[8..12].copy_from_slice(&encode_color(cell.bg()).to_le_bytes());
428 r[12..14].copy_from_slice(&cell.flags().bits().to_le_bytes());
429 r
430}
431
432/// A colour reference as a tagged u32: high byte = tag
433/// (0 = Default, 1 = Indexed, 2 = Rgb), low 24 bits = payload. The tag is
434/// mandatory so `Default`, `Indexed(0)`, and `Rgb(0,0,0)` stay distinct.
435///
436/// Public so an alternate consumer (the WASM decoder's structure-of-arrays
437/// `fg`/`bg` columns, #35) reuses this single definition of the colour-ref
438/// encoding instead of re-implementing the tag packing — no drift.
439pub fn encode_color(c: Color) -> u32 {
440 match c {
441 Color::Default => 0,
442 Color::Indexed(i) => (1 << 24) | i as u32,
443 Color::Rgb(r, g, b) => (2 << 24) | (r as u32) << 16 | (g as u32) << 8 | b as u32,
444 }
445}
446
447/// Deserialize the binary wire format back into a [`Frame`].
448pub fn decode(bytes: &[u8]) -> Result<Frame, DecodeError> {
449 let mut r = Reader::new(bytes);
450 if r.take(2)? != MAGIC {
451 return Err(DecodeError::BadMagic);
452 }
453 let version = r.u8()?;
454 if version != VERSION {
455 return Err(DecodeError::BadVersion(version));
456 }
457 let has_scroll = r.u8()? != 0;
458 let kind = match r.u8()? {
459 0 => FrameKind::Full,
460 1 => FrameKind::Partial,
461 _ => return Err(DecodeError::BadTag),
462 };
463 let cols = r.u16()?;
464 let rows = r.u16()?;
465 let cursor_row = r.u16()?;
466 let cursor_col = r.u16()?;
467 let cursor_visible = r.u8()? != 0;
468 let cursor_shape = match r.u8()? {
469 0 => CursorShape::Block,
470 1 => CursorShape::Underline,
471 2 => CursorShape::Bar,
472 _ => return Err(DecodeError::BadTag),
473 };
474 let cursor_blink = r.u8()? != 0;
475 let display_offset = r.u32()?;
476 let scrollback_len = r.u32()?;
477 let mouse_events = MouseEvents::from_bits_retain(r.u8()?);
478 let alt_screen = r.u8()? != 0;
479 let scroll = if has_scroll {
480 let top = r.u16()? as usize;
481 let bottom = r.u16()? as usize;
482 let count = (r.u16()? as i16) as isize;
483 Some(ScrollOp { top, bottom, count })
484 } else {
485 None
486 };
487 let span_count = r.u16()?;
488 let mut spans = Vec::with_capacity(span_count as usize);
489 for _ in 0..span_count {
490 let line = r.u16()?;
491 let left = r.u16()?;
492 let right = r.u16()?;
493 if right < left {
494 return Err(DecodeError::BadSpan);
495 }
496 // Widen before the arithmetic: `right - left + 1` in `u16` overflows
497 // when `right == u16::MAX` (e.g. left=0, right=65535), panicking under
498 // overflow checks. `right >= left` is enforced just above, so the
499 // subtraction in `usize` cannot underflow.
500 let n = right as usize - left as usize + 1;
501 let mut cells = Vec::with_capacity(n);
502 for _ in 0..n {
503 cells.push(decode_cell(&mut r)?);
504 }
505 spans.push(Span {
506 line,
507 left,
508 right,
509 cells,
510 // Filled from their own groups below (v14): the record no longer carries
511 // either reference, so neither the maps nor the cells' presence bits can
512 // be built here.
513 combining: BTreeMap::new(),
514 links: BTreeMap::new(),
515 ucolors: BTreeMap::new(),
516 });
517 }
518 // Counts and lengths are u32 since v14 — see `encode`. Note the deliberate loss of
519 // `Vec::with_capacity`: these counts are attacker-influenced (`tests/robustness.rs`
520 // drives `decode` from arbitrary bytes) and a u32 one can now declare 4 billion
521 // entries, so reserving up front would turn a 12-byte buffer into an OOM. Growing
522 // as the entries actually arrive is bounded by the input's own length.
523 let link_count = r.u32()?;
524 let mut link_table = Vec::new();
525 for _ in 0..link_count {
526 let len = r.u32()? as usize;
527 let bytes = r.take(len)?;
528 link_table.push(String::from_utf8_lossy(bytes).into_owned());
529 }
530 // Combining group (v14, #621): inverse of the encode above — one sparse map per
531 // span, in span order, attached to `spans[i]` positionally.
532 //
533 // Re-arming `C_COMBINED` here is not a nicety, it is the only place left that can.
534 // The bit never travels *as a bit*: the record encodes `cell.c()`, the char, so
535 // the content word's marker is dropped. Until v14 `decode_cell` could reconstruct
536 // it inline from `extra != 0` because the index rode the record; now that it does
537 // not, this loop inherits that duty — the same reconstruction `ucolors` has always
538 // done, and the defect #531 was filed for when it was missing.
539 //
540 // Bounds-gated on the same terms as `ucolors`: `col` is attacker-influenced and
541 // nothing on the wire bounds it against the span's width, so an out-of-range key
542 // arms no cell and is left in the map. Whether it should be *rejected* is #582's
543 // question, and answering half of it here would pre-empt that decision.
544 for span in &mut spans {
545 let count = r.u32()?;
546 for _ in 0..count {
547 let col = r.u16()? as usize;
548 let len = r.u32()?;
549 let mut cluster = Vec::new();
550 for _ in 0..len {
551 cluster.push(char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?);
552 }
553 if let Some(cell) = span.cells.get_mut(col) {
554 cell.set_combined(true);
555 }
556 span.combining.insert(col, cluster);
557 }
558 }
559 // Hyperlink reference group (v14, #621): same shape, and `LINK_PRESENT` is re-armed
560 // for the same reason — it lives in the bg word, which `encode_color` drops.
561 for span in &mut spans {
562 let count = r.u32()?;
563 for _ in 0..count {
564 let col = r.u16()? as usize;
565 let idx = NonZeroU32::new(r.u32()?).ok_or(DecodeError::BadTag)?;
566 if let Some(cell) = span.cells.get_mut(col) {
567 cell.set_linked(true);
568 }
569 span.links.insert(col, idx);
570 }
571 }
572 // Underline-colour group (v13, #520): inverse of the encode above — one sparse
573 // map per span, in span order, so each attaches to `spans[i]` positionally.
574 for span in &mut spans {
575 let count = r.u16()?;
576 for _ in 0..count {
577 let col = r.u16()? as usize;
578 let color = decode_color(r.u32()?)?;
579 // Re-arm `UCOLOR_PRESENT` from the group (#531). On this wire a presence
580 // bit never travels *as a bit*: it lives in the bg word (`BG_UCOLOR`),
581 // which `encode_color` drops when it keeps only mode+value, and
582 // `CellFlags` carries no presence bits. So every one of them is
583 // *reconstructed* from whether its group carries an entry — the same
584 // derivation `decode_cell` performs for `combined`/`linked`, which can do
585 // it inline only because `extra`/`link` ride the cell record while a
586 // colour reference rides a separate group. `Row::ucolor_at` gates the map
587 // read on this bit, so without the re-arm `Cell::is_ucolored()` returns
588 // false on a decoded cell whose column *does* carry a colour.
589 //
590 // Bounds-gated on purpose: `col` is attacker-influenced (see
591 // `tests/robustness.rs`) and nothing on the wire bounds it against the
592 // span's width, so an unchecked `span.cells[col]` would panic where
593 // ADR-0008 owes a typed error. An out-of-range key arms no cell and is
594 // left in the map — whether it should instead be *rejected* is #582's
595 // question (a group riding a frame it does not fit), and answering half
596 // of it here would pre-empt that decision.
597 if let Some(cell) = span.cells.get_mut(col) {
598 cell.set_ucolored(true);
599 }
600 span.ucolors.insert(col, color);
601 }
602 }
603 // Overlay section (#108): selection group then match group, each a count +
604 // `(row, left, right)` triples (inverse of `encode_overlay_spans`).
605 let selection = decode_overlay_spans(&mut r)?;
606 let matches = decode_overlay_spans(&mut r)?;
607 // Third group (#118): marker `(id u32, row u16)` records, each followed by a
608 // kind discriminant (v10, #159) and — for `CommandFinished` — a presence byte
609 // + i32 exit (inverse of the marker encode loop).
610 let marker_count = r.u16()?;
611 let mut markers = Vec::with_capacity(marker_count as usize);
612 for _ in 0..marker_count {
613 let id = MarkerId(r.u32()?);
614 let row = r.u16()? as usize;
615 let kind = match r.u8()? {
616 0 => MarkerKind::Plain,
617 1 => MarkerKind::PromptStart,
618 2 => MarkerKind::CommandStart,
619 3 => MarkerKind::OutputStart,
620 4 => {
621 // Always read presence + i32 (the encoder writes both); a 0
622 // presence means the exit bytes are padding to discard.
623 let present = r.u8()? != 0;
624 let exit = r.u32()? as i32;
625 MarkerKind::CommandFinished(present.then_some(exit))
626 }
627 _ => return Err(DecodeError::BadTag),
628 };
629 markers.push(MarkerPosition { id, row, kind });
630 }
631 // Fourth group (#120 S3, v11): every live marker's `(id u32, line u32)` — the
632 // absolute-line superset for the overview ruler (inverse of the encode loop).
633 let marker_line_count = r.u16()?;
634 let mut marker_lines = Vec::with_capacity(marker_line_count as usize);
635 for _ in 0..marker_line_count {
636 let id = MarkerId(r.u32()?);
637 let line = r.u32()?;
638 marker_lines.push(MarkerLine { id, line });
639 }
640 // Fifth group (#428, v12): the active search match's spans (inverse of the
641 // tail `encode_overlay_spans` call).
642 let active_match = decode_overlay_spans(&mut r)?;
643 let overlay = Overlay {
644 selection,
645 matches,
646 markers,
647 marker_lines,
648 active_match,
649 };
650 Ok(Frame {
651 cols,
652 rows,
653 kind,
654 cursor_row,
655 cursor_col,
656 cursor_visible,
657 cursor_shape,
658 cursor_blink,
659 display_offset,
660 scrollback_len,
661 mouse_events,
662 alt_screen,
663 scroll,
664 spans,
665 link_table,
666 overlay,
667 })
668}
669
670/// Decode one overlay group: a u16 span count, then that many `(row, left,
671/// right)` u16 triples back into viewport [`SelectionSpan`]s (inverse of
672/// [`encode_overlay_spans`]).
673fn decode_overlay_spans(r: &mut Reader) -> Result<Vec<SelectionSpan>, DecodeError> {
674 let count = r.u32()?;
675 // No `with_capacity`: the count is attacker-influenced and now u32 — see `decode`.
676 let mut spans = Vec::new();
677 for _ in 0..count {
678 let row = r.u16()? as usize;
679 let left = r.u16()? as usize;
680 let right = r.u16()? as usize;
681 spans.push(SelectionSpan { row, left, right });
682 }
683 Ok(spans)
684}
685
686/// Decode one 14-byte cell record (inverse of [`encode_cell_record`]).
687///
688/// **No presence bit is re-armed here — since v14 (#621), none of the three can be.**
689/// They are reconstructed rather than transmitted (they live in the packed content
690/// and colour words, which [`encode_color`] and `Cell::c` strip), and every one of
691/// them now has its evidence in a sparse group read further down the buffer:
692/// `C_COMBINED` and `LINK_PRESENT` from the combining and link groups, and
693/// `UCOLOR_PRESENT` from the underline-colour group as it always was (#531).
694///
695/// This doc used to say *two of the three are re-armed here*, and that exception —
696/// "mine rides the record, so it needs nothing" — is the reading #531 was filed for.
697/// There is no longer a member of the set it could apply to.
698fn decode_cell(r: &mut Reader) -> Result<Cell, DecodeError> {
699 let c = char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?;
700 let fg = decode_color(r.u32()?)?;
701 let bg = decode_color(r.u32()?)?;
702 let flags = CellFlags::from_bits_retain(r.u16()?);
703 // `C_COMBINED` and `LINK_PRESENT` are deliberately left off here and re-armed by
704 // `decode` from the per-span groups (v14, #621). This function used to set them
705 // from the record's `extra`/`link`; with those gone it has nothing to read, and
706 // guessing `false` is correct precisely because the groups are the authority.
707 Ok(Cell::from_parts(c, fg, bg, flags))
708}
709
710/// Decode a tagged-u32 colour reference (inverse of [`encode_color`]).
711fn decode_color(v: u32) -> Result<Color, DecodeError> {
712 let payload = v & 0x00FF_FFFF;
713 match v >> 24 {
714 0 => Ok(Color::Default),
715 1 => Ok(Color::Indexed(payload as u8)),
716 2 => Ok(Color::Rgb(
717 (payload >> 16) as u8,
718 (payload >> 8) as u8,
719 payload as u8,
720 )),
721 _ => Err(DecodeError::BadTag),
722 }
723}
724
725/// A little-endian cursor over the wire bytes.
726struct Reader<'a> {
727 bytes: &'a [u8],
728 pos: usize,
729}
730
731impl<'a> Reader<'a> {
732 fn new(bytes: &'a [u8]) -> Self {
733 Reader { bytes, pos: 0 }
734 }
735
736 fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
737 let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
738 let slice = self
739 .bytes
740 .get(self.pos..end)
741 .ok_or(DecodeError::Truncated)?;
742 self.pos = end;
743 Ok(slice)
744 }
745
746 fn u8(&mut self) -> Result<u8, DecodeError> {
747 Ok(self.take(1)?[0])
748 }
749
750 fn u16(&mut self) -> Result<u16, DecodeError> {
751 let b = self.take(2)?;
752 Ok(u16::from_le_bytes([b[0], b[1]]))
753 }
754
755 fn u32(&mut self) -> Result<u32, DecodeError> {
756 let b = self.take(4)?;
757 Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
758 }
759}