diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 13:27:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch) | |
| tree | 803194ee5853a6b4cda93f90a95e28d1f02e69ae /src/detached/wire.zig | |
| parent | fbc194068687e49a8490c85c9f1257a2f2bb9079 (diff) | |
| download | pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip | |
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol
`pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns
a terminal and a socket and nothing else. N frontends on one core all look at the same screen —
`screen -x`, not N sessions.
The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per
message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the
core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is
a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin
command line, and a command line cannot carry a frame.
ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be
riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit
little-endian fixed width and no message is a blit of a native struct. A protocol that only works
between two builds of the same compiler would have thrown away the one frontend that motivated it.
## The board comes in; its toolchain stays out
`src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over-
serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves
into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so
`zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the
board from this repo's `build.zig`.
The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes
core over a serial line. What stayed is everything a second project would also want — the HAL, the
register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its
own and its `build()` early-returns when it is not the root package, so this costs the package
graph exactly zero packages and the editor's own builds nothing at all.
## limits.zig: nine forgettable places become one budget
Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions —
they are ONE decision, how much memory this build may spend, taken nine times where no reader could
see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against
what it is measured against, derived from two booleans.
The payoff is testability on a machine that is not the board: the caps are ordinary comptime values,
so a host build can be compiled against the board's numbers and the parking, eviction and clamping
paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART.
## A bare `zig build`
`zig build` with no arguments now builds the tty and GUI binaries and installs them into
`~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built
one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and
"use it" two different commands for no reason.
Diffstat (limited to 'src/detached/wire.zig')
| -rw-r--r-- | src/detached/wire.zig | 1755 |
1 files changed, 1755 insertions, 0 deletions
diff --git a/src/detached/wire.zig b/src/detached/wire.zig new file mode 100644 index 00000000..f2030e26 --- /dev/null +++ b/src/detached/wire.zig @@ -0,0 +1,1755 @@ +//! THE DETACHED-SESSION WIRE FORMAT: one `Event` and one `Host.VTable` call per +//! message, byte for byte, with nothing native about the bytes. +//! +//! WHO OWNS THE CORE. The `Pardes` instance lives in the DETACHED process +//! (server.zig). A frontend (client.zig) owns a terminal and a socket and +//! nothing else: it sends the input it collects and draws the frames it is +//! sent. One core per session, N frontends attached to it, all looking at the +//! same screen — `screen -x`, not N sessions. +//! +//! WHY A CODEC AT ALL, when nested.zig's socket carries a builtin command line +//! and has nothing to version: a command line cannot carry a frame, and frames +//! and input are this transport's entire content. +//! +//! ARCHITECTURE-NEUTRAL, and not as decoration: the frontend on the other end +//! may be riscv32-freestanding (the ESP32-P4 board) while the core is x86_64 +//! linux. So: +//! * every integer is an explicit width, little-endian. No `usize` reaches +//! the wire — a pointer-sized field is 4 bytes on the board and 8 here, and +//! every field after it would then be read at the wrong offset. +//! * no native struct is ever blitted. `@bitCast`/`std.mem.asBytes` of a Zig +//! struct puts this compiler's field order and padding on a socket; every +//! field below is written and read by hand. +//! * every union and every enum gets a tag chosen HERE (`ClientTag`, +//! `ServerTag`, `ColorTag`, ...) and never `@intFromEnum` of a core type, +//! so reordering `Event` or `CellStyle.ul` cannot silently redefine the +//! protocol. The mapping switches are exhaustive: adding a variant to the +//! core is a compile error in this file, which is the point of them. +//! * every variable-length payload carries an explicit length prefix, and +//! `max_payload` bounds the lot. This is a parser on a socket: a malformed +//! frame must be REFUSED, never indexed past. +//! * a bool is one byte, 0 or 1. Any other value is a decode error rather +//! than "nonzero is true": a byte this protocol cannot mean is evidence +//! the stream is not the stream it claims to be. +//! * floats travel as their IEEE-754 binary32 bit pattern inside an explicit +//! u32. Both ends agree about binary32; neither agrees about struct layout. +//! +//! BUILD-NEUTRAL for the same reason. `Event.resize.cell_pixels` exists only +//! when native PDF placement is compiled in (pardes.zig `CellPixels`), and a +//! frontend must not have to have been built with the core's options — so it is +//! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. +//! +//! WHAT IS NOT HERE. The seam has twenty methods; this carries twelve of +//! them, and the eight it does not are named here with their reasons. +//! * `pull_wait_input` IS the server's poll loop, not a message. +//! * `push_poll_frame` and `push_post_present` carry no information. They are +//! per-frame bookkeeping ticks, and `frame` already arrives exactly once +//! per pump at the same place in the order — a frontend does its per-frame +//! work when a frame lands. Two more messages per frame per client would +//! say nothing the frame does not already say. +//! * `pull_tty_taken` and `pull_gpio_toggle` are answers the CALLER waits +//! for, and `pull_lsp`/`pull_pipe` are work dispatched off the loop. A +//! round trip inside `update` is the one thing this transport must never +//! do: the core would block on a socket, and `pull_wait_input`'s own +//! comment is that it is the only place this process may sleep. The +//! process that owns the core answers all four. +//! * `push_fs_reply` cannot be a broadcast. host.zig's rule is that the +//! transport which asked is the one holding the request; with N frontends, +//! N-1 would receive the answer to a request they never made. So the acme +//! mount stays in the detached process, where the `Event.fs_req` that +//! starts it is raised, and neither half of that pair is on the wire — +//! which is also why `Event.fs_req` has no `ClientTag`. +const std = @import("std"); +const pardes = @import("../pardes.zig"); + +/// Bumped whenever any layout below changes. Checked on connect and refused +/// loudly (see `Refusal.version`): two builds of pardes are routinely on one +/// machine — `zig build` replaces the binary under a running session — and a +/// frontend decoding another version's frame layout would paint garbage and +/// blame the terminal. +pub const version: u16 = 1; + +pub const Error = error{ + /// The message ended inside a field. + Truncated, + /// A length prefix, a run, or a grid dimension larger than this protocol + /// admits. Refused before anything is allocated or indexed. + Overlong, + /// A tag byte no version of this protocol has ever defined. + BadTag, + /// A tag this protocol does define, carrying a value it cannot mean: a + /// 3-in-a-bool, a zero-column resize, a pane past MAX_PANES. + BadValue, + /// The payload was decoded and bytes were left over. A message that says + /// more than its layout has room for is not this message. + Trailing, + /// The encoder ran out of caller-supplied buffer. + NoSpace, +}; + +// --------------------------------------------------------------------------- +// bounds +// --------------------------------------------------------------------------- + +/// The largest grid this protocol carries. `Surface.cols`/`rows` are u16, so +/// these are protocol bounds rather than type bounds, and they exist because +/// `max_payload` below is derived from them: a decoder that accepts 65535 +/// columns accepts a 25 GiB frame prefix. A 4K display at a 6-pixel font is +/// about 340 columns and 110 rows, so this is roughly 1.5x the largest grid +/// any real terminal has, and the board's own is 56x14. +pub const max_cols: u16 = 512; +pub const max_rows: u16 = 128; + +/// One cell at its largest: `default` false, a 7-byte grapheme, two rgb colors, +/// the attribute byte, the underline style and the font role. Written as the +/// sum of the fields rather than a number so that adding a field to `Cell` +/// moves it. +const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1; + +/// `start:u32 + count:u16`. A run's cost, and therefore the break-even the +/// encoder coalesces against (see `encodeFrame`). +const run_header = 4 + 2; + +/// `kind:u8 + cols:u16 + rows:u16 + cursor(6) + nruns:u32`. +const frame_head = 1 + 2 + 2 + 6 + 4; + +/// The longest legal payload, and therefore the length prefix a decoder will +/// accept before it refuses the stream. Three messages set it: +/// * a full frame of the largest grid, worst case one run per cell: +/// 512*128 * (6 + 20) = 1.6 MiB. +/// * one paste, which the tty frontend already caps at 4 MiB (tty.zig +/// `max_paste_bytes`) on the grounds that anything larger is a mis-click. +/// * a `write_file`, whose bytes are a pane's whole text and are the only +/// genuinely open-ended payload here. +/// 16 MiB is past every source file anyone edits in this editor and is still a +/// buffer the receiving side can simply hold. A larger message is not sent and +/// a larger prefix is not read. +pub const max_payload: u32 = 16 << 20; + +/// Every message is `tag:u8, len:u32le, payload[len]`. A u32 because a full +/// frame and a paste both pass 64 KiB; a u16 would have needed the frame split +/// across messages, which is a second framing layer for no gain. +pub const header_len = 5; + +/// Bytes `encodeFrame` may need for this grid, worst case: every cell changed, +/// every cell in a run of its own, every cell at `cell_max`. The server sizes +/// one buffer from this per geometry rather than guessing. +pub fn frameBound(cols: u16, rows: u16) usize { + return header_len + frame_head + @as(usize, cols) * @as(usize, rows) * (run_header + cell_max); +} + +// --------------------------------------------------------------------------- +// tags +// --------------------------------------------------------------------------- + +/// Frontend -> core. Exhaustive on purpose, which is the opposite of +/// fuse.zig's `Opcode`: there, a newer KERNEL adds opcodes and a non-exhaustive +/// enum is the only way to receive one without undefined behaviour. Here both +/// ends are pardes and an unknown tag is not a newer peer — `version` already +/// refused that — so it is a corrupt or hostile stream and must be rejected. +/// `std.enums.fromInt` is how, at the one place a byte becomes a tag. +/// +/// The numbers are the PROTOCOL's, grouped session/input rather than derived +/// from `Event`'s declaration order, so reordering the union changes nothing. +pub const ClientTag = enum(u8) { + hello = 0x01, + bye = 0x02, + + key = 0x10, + mouse = 0x11, + resize = 0x12, + output = 0x13, + eof = 0x14, + lsp_resp = 0x15, + pipe_resp = 0x16, + file_changed = 0x17, + paste = 0x18, + command = 0x19, + pdf_scroll = 0x1a, + pinch = 0x1b, + touch_scroll = 0x1c, + pointer_leave = 0x1d, + tick = 0x1e, +}; + +/// Core -> frontend. 0x01..0x0f is the session, 0x10.. is one `push_` method +/// each, in `Host.VTable`'s own order so the two lists can be read side by +/// side. +pub const ServerTag = enum(u8) { + welcome = 0x01, + refuse = 0x02, + frame = 0x03, + quit = 0x04, + + spawn = 0x10, + pty_write = 0x11, + pty_resize = 0x12, + write_file = 0x13, + write_dump = 0x14, + watch_file = 0x15, + watch_theme = 0x16, + dump_themes = 0x17, + set_clipboard = 0x18, + read_clipboard = 0x19, + open_link = 0x1a, +}; + +/// Why the server hung up on a connect. Sent as a `refuse` and followed by a +/// close, so a frontend can say something specific instead of "connection +/// closed". +pub const Refusal = enum(u8) { + /// `Hello.version` is not `version`. The frontend and the core are two + /// builds of pardes. + version = 0x01, + /// Every client slot is taken (see server.zig `max_clients`). + full = 0x02, + /// The core has already quit; this session is ending. + quitting = 0x03, +}; + +const ColorTag = enum(u8) { default = 0x00, index = 0x01, rgb = 0x02 }; +const UlTag = enum(u8) { off = 0x00, single = 0x01, double = 0x02, curly = 0x03, dotted = 0x04, dashed = 0x05 }; +const FontTag = enum(u8) { body = 0x00, tagline = 0x01 }; +const ButtonTag = enum(u8) { + left = 0x00, + middle = 0x01, + right = 0x02, + wheel_up = 0x03, + wheel_down = 0x04, + wheel_left = 0x05, + wheel_right = 0x06, + none = 0x07, +}; +const KindTag = enum(u8) { press = 0x00, release = 0x01, motion = 0x02, drag = 0x03 }; + +/// A full frame resets the receiver's grid to unpainted cells and then applies +/// its runs; a diff applies its runs on top of what is already there. One bit +/// of semantics and one code path, and it is what makes a full frame of a +/// mostly-empty grid cheap. +pub const FrameKind = enum(u8) { full = 0x01, diff = 0x02 }; + +/// Bit per `CellStyle` bool, packed into one byte. Bit 7 is unassigned and a +/// set bit 7 is a decode error: it is a byte this protocol cannot mean. +const attr_bold: u8 = 1 << 0; +const attr_dim: u8 = 1 << 1; +const attr_italic: u8 = 1 << 2; +const attr_blink: u8 = 1 << 3; +const attr_reverse: u8 = 1 << 4; +const attr_invisible: u8 = 1 << 5; +const attr_strikethrough: u8 = 1 << 6; +const attr_reserved: u8 = 1 << 7; + +// --------------------------------------------------------------------------- +// messages +// --------------------------------------------------------------------------- + +/// First message on every connection, and `version` is its first field at a +/// fixed offset for exactly one reason: a mismatch has to be diagnosable even +/// when the rest of the layout is the part that changed. +pub const Hello = struct { + version: u16 = version, + /// This frontend's grid. Never zero — see `Cursor` for why zero is refused + /// rather than clamped. + cols: u16, + rows: u16, +}; + +pub const Welcome = struct { + version: u16 = version, + /// Which client slot this connection got. Carried because it is what the + /// server's own diagnostics name, so both sides say the same number. + slot: u8, + /// The session's grid as of this attach — the smallest common one, which + /// may be smaller than the `Hello` asked for. See server.zig `geometry`. + cols: u16, + rows: u16, +}; + +pub const Cursor = struct { x: u16, y: u16, bar: bool }; + +/// One frame, head decoded and runs left encoded. The runs are NOT expanded +/// into a slice of cells here: a frame of the largest grid is 1.6 MiB, this +/// union is passed by value, and the receiver already owns the grid the runs +/// belong in. `apply` is the bounds-checked walk. +pub const Frame = struct { + kind: FrameKind, + cols: u16, + rows: u16, + cursor: ?Cursor, + nruns: u32, + runs: []const u8, + + /// Paint this frame into `grid`, which must be exactly `cols * rows` cells + /// — the receiver resizes on a geometry change before applying, and a grid + /// of the wrong size is a receiver bug, not a wire condition. + /// + /// Every run is range-checked against the grid before a single cell is + /// written, so a run claiming to start past the end writes nothing. + pub fn apply(f: Frame, grid: []pardes.Cell) Error!void { + if (grid.len != @as(usize, f.cols) * @as(usize, f.rows)) return error.BadValue; + if (f.kind == .full) @memset(grid, .{}); + var r: Reader = .init(f.runs); + var i: u32 = 0; + while (i < f.nruns) : (i += 1) { + const start = try r.getU32(); + const count = try r.getU16(); + // A zero-length run is not something `encodeFrame` emits, and + // accepting one would let a peer spend the run budget saying + // nothing. + if (count == 0) return error.BadValue; + // THE bounds check. Written as `count > len - start` rather than + // `start + count > len` because the sum of two attacker-chosen + // 32-bit numbers is the classic way this check is bypassed. + if (start > grid.len or count > grid.len - start) return error.Overlong; + for (grid[start..][0..count]) |*c| c.* = try decodeCell(&r); + } + try r.end(); + } +}; + +/// Frontend -> core, decoded. The `Event`'s slices BORROW the payload buffer, +/// exactly like the pty chunks the tty host hands to `update`: valid for that +/// one call and no longer. +pub const ClientMsg = union(enum) { + hello: Hello, + bye, + event: pardes.Event, +}; + +/// Core -> frontend, decoded. Slices borrow the payload buffer the same way. +pub const ServerMsg = union(enum) { + welcome: Welcome, + refuse: Refusal, + frame: Frame, + /// The session is over. Sent before the listener closes so a frontend can + /// exit rather than report a broken pipe. + quit, + + spawn: struct { pane: u8, cwd: []const u8 }, + pty_write: struct { pane: u8, bytes: []const u8 }, + pty_resize: struct { pane: u8, cols: u16, rows: u16 }, + write_file: struct { pane: u8, path: []const u8, bytes: []const u8 }, + write_dump: []const u8, + watch_file: struct { pane: u8, path: []const u8, on: bool }, + watch_theme: struct { generation: u32, on: bool }, + dump_themes: struct { pane: u8 }, + set_clipboard: []const u8, + read_clipboard, + open_link: []const u8, +}; + +/// The one thing a decoder cannot put in a byte buffer: `pipe_resp.outputs` is +/// a `[]const []const u8`, so the outer array needs somewhere to live. Sized +/// from the core's own ceiling on selections (`MAX_SELS`), which is what bounds +/// the count a legitimate `pipe_resp` can carry. +pub const Scratch = struct { + outputs: [pardes.MAX_SELS][]const u8 = undefined, +}; + +// --------------------------------------------------------------------------- +// primitives +// --------------------------------------------------------------------------- + +pub const Writer = struct { + buf: []u8, + n: usize = 0, + + pub fn init(buf: []u8) Writer { + return .{ .buf = buf }; + } + + pub fn written(w: *const Writer) []const u8 { + return w.buf[0..w.n]; + } + + fn room(w: *Writer, k: usize) Error![]u8 { + if (w.buf.len - w.n < k) return error.NoSpace; + defer w.n += k; + return w.buf[w.n..][0..k]; + } + + fn putByte(w: *Writer, v: u8) Error!void { + (try w.room(1))[0] = v; + } + + fn putU16(w: *Writer, v: u16) Error!void { + std.mem.writeInt(u16, (try w.room(2))[0..2], v, .little); + } + + fn putU32(w: *Writer, v: u32) Error!void { + std.mem.writeInt(u32, (try w.room(4))[0..4], v, .little); + } + + /// One byte, 0 or 1, never "nonzero". `getBool` refuses anything else. + fn putBool(w: *Writer, v: bool) Error!void { + try w.putByte(@intFromBool(v)); + } + + /// IEEE-754 binary32, as its bit pattern in an explicit u32. A scalar + /// bitcast and not a struct blit: the format is the one thing a riscv32 + /// and an x86_64 do agree about. + fn putF32(w: *Writer, v: f32) Error!void { + try w.putU32(@bitCast(v)); + } + + fn putBytes(w: *Writer, v: []const u8) Error!void { + @memcpy(try w.room(v.len), v); + } + + /// Length-prefixed, u16: paths, command lines, a key's text. Nothing here + /// is legitimately longer than 64 KiB and a u16 says so on the wire. + fn putSlice16(w: *Writer, v: []const u8) Error!void { + if (v.len > std.math.maxInt(u16)) return error.Overlong; + try w.putU16(@intCast(v.len)); + try w.putBytes(v); + } + + /// ...and u32 for the ones that are: pty output, a paste, a file. + fn putSlice32(w: *Writer, v: []const u8) Error!void { + if (v.len > max_payload) return error.Overlong; + try w.putU32(@intCast(v.len)); + try w.putBytes(v); + } +}; + +pub const Reader = struct { + bytes: []const u8, + i: usize = 0, + + pub fn init(bytes: []const u8) Reader { + return .{ .bytes = bytes }; + } + + /// `i <= bytes.len` is the invariant every getter below preserves, which is + /// what makes the subtraction here safe. + fn take(r: *Reader, n: usize) Error![]const u8 { + if (r.bytes.len - r.i < n) return error.Truncated; + defer r.i += n; + return r.bytes[r.i..][0..n]; + } + + fn getByte(r: *Reader) Error!u8 { + return (try r.take(1))[0]; + } + + fn getU16(r: *Reader) Error!u16 { + return std.mem.readInt(u16, (try r.take(2))[0..2], .little); + } + + fn getU32(r: *Reader) Error!u32 { + return std.mem.readInt(u32, (try r.take(4))[0..4], .little); + } + + fn getBool(r: *Reader) Error!bool { + return switch (try r.getByte()) { + 0 => false, + 1 => true, + else => error.BadValue, + }; + } + + fn getF32(r: *Reader) Error!f32 { + const v: f32 = @bitCast(try r.getU32()); + // A NaN or an infinity here is not a scroll distance. `pinch` in + // particular multiplies into a zoom factor the pane keeps, so one bad + // value poisons that pane for the rest of the session. + if (!std.math.isFinite(v)) return error.BadValue; + return v; + } + + fn getSlice16(r: *Reader) Error![]const u8 { + return r.take(try r.getU16()); + } + + fn getSlice32(r: *Reader) Error![]const u8 { + const n = try r.getU32(); + if (n > max_payload) return error.Overlong; + return r.take(n); + } + + /// A pane id the core can actually index. `Event.output{ .pane = 200 }` + /// would reach `p.panes[200]`. + fn getPane(r: *Reader) Error!u8 { + const pane = try r.getByte(); + if (pane >= pardes.MAX_PANES) return error.BadValue; + return pane; + } + + /// A grid the core can render into. Zero is refused rather than clamped: a + /// frontend that has not been sized yet must not be allowed to collapse a + /// shared session to nothing (see server.zig `geometry`). + fn getCols(r: *Reader) Error!u16 { + const v = try r.getU16(); + if (v == 0 or v > max_cols) return error.BadValue; + return v; + } + + fn getRows(r: *Reader) Error!u16 { + const v = try r.getU16(); + if (v == 0 or v > max_rows) return error.BadValue; + return v; + } + + fn getTag(r: *Reader, comptime T: type) Error!T { + return std.enums.fromInt(T, try r.getByte()) orelse error.BadTag; + } + + fn end(r: *Reader) Error!void { + if (r.i != r.bytes.len) return error.Trailing; + } +}; + +/// Reserve a message header; `finishMessage` back-patches the length. Two +/// passes would mean walking a frame's runs twice to find out how long they +/// are, and the walk is the expensive half. +fn beginMessage(w: *Writer, tag: u8) Error!usize { + const at = w.n; + try w.putByte(tag); + try w.putU32(0); + return at; +} + +fn finishMessage(w: *Writer, at: usize) Error!void { + const len = w.n - at - header_len; + if (len > max_payload) return error.Overlong; + std.mem.writeInt(u32, w.buf[at + 1 ..][0..4], @intCast(len), .little); +} + +/// One complete message at the front of a stream buffer, or null when the rest +/// has not arrived. `total` is what the caller consumes. +pub const Framed = struct { tag: u8, payload: []const u8, total: usize }; + +pub fn framed(buf: []const u8) Error!?Framed { + if (buf.len < header_len) return null; + const len = std.mem.readInt(u32, buf[1..5], .little); + // Refused BEFORE the caller grows a buffer to hold it: an over-long prefix + // is the one field in this protocol that can ask for memory. + if (len > max_payload) return error.Overlong; + if (buf.len - header_len < len) return null; + return .{ .tag = buf[0], .payload = buf[header_len..][0..len], .total = header_len + len }; +} + +// --------------------------------------------------------------------------- +// cells +// --------------------------------------------------------------------------- + +fn putColor(w: *Writer, c: pardes.Color) Error!void { + switch (c) { + .default => try w.putByte(@intFromEnum(ColorTag.default)), + .index => |i| { + try w.putByte(@intFromEnum(ColorTag.index)); + try w.putByte(i); + }, + .rgb => |v| { + try w.putByte(@intFromEnum(ColorTag.rgb)); + try w.putBytes(&v); + }, + } +} + +fn getColor(r: *Reader) Error!pardes.Color { + return switch (try r.getTag(ColorTag)) { + .default => .default, + .index => .{ .index = try r.getByte() }, + .rgb => .{ .rgb = (try r.take(3))[0..3].* }, + }; +} + +const Ul = @FieldType(pardes.CellStyle, "ul"); + +/// The four enum mappings — underline, font role, mouse button, mouse kind — +/// and `clientTag`/`serverTag` further down all have one shape: the WIRE +/// member of the same NAME. So the byte on the socket is still `@intFromEnum` +/// of an explicitly numbered enum in THIS file and never of a core type — +/// reordering `CellStyle.ul` changes nothing — and adding a variant to the +/// core without adding one here does not compile: +/// +/// error: enum 'wire.UlTag' has no member named 'wavy' +/// +/// which is the whole property the six hand-written switches this replaced +/// existed for, in a form that cannot fall out of step. A variant that has to +/// travel under a DIFFERENT name than the core's gets an arm of its own before +/// the `inline else`, exactly as `clientTag` keeps `.fs_req`. +fn putUl(w: *Writer, u: Ul) Error!void { + try w.putByte(switch (u) { + inline else => |t| @intFromEnum(@field(UlTag, @tagName(t))), + }); +} + +fn getUl(r: *Reader) Error!Ul { + return switch (try r.getTag(UlTag)) { + inline else => |t| @field(Ul, @tagName(t)), + }; +} + +fn putFont(w: *Writer, f: pardes.FontRole) Error!void { + try w.putByte(switch (f) { + inline else => |t| @intFromEnum(@field(FontTag, @tagName(t))), + }); +} + +fn getFont(r: *Reader) Error!pardes.FontRole { + return switch (try r.getTag(FontTag)) { + inline else => |t| @field(pardes.FontRole, @tagName(t)), + }; +} + +/// How many bytes `putCell` will write. Exact, because `encodeFrame` weighs it +/// against `run_header` to decide whether to coalesce a run across it. +fn cellSize(c: *const pardes.Cell) usize { + if (c.default) return 1; + return 1 + 1 + c.len + colorSize(c.style.fg) + colorSize(c.style.bg) + 1 + 1 + 1; +} + +fn colorSize(c: pardes.Color) usize { + return switch (c) { + .default => 1, + .index => 2, + .rgb => 4, + }; +} + +/// An unpainted cell is ONE byte: `default` means "the shell renders the +/// terminal's default cell" (pardes.zig `Cell`), so its text and style are not +/// merely equal to the defaults, they are not part of the frame at all. +fn putCell(w: *Writer, c: *const pardes.Cell) Error!void { + try w.putBool(c.default); + if (c.default) return; + try w.putByte(c.len); + try w.putBytes(c.grapheme()); + const s = c.style; + try putColor(w, s.fg); + try putColor(w, s.bg); + var attrs: u8 = 0; + if (s.bold) attrs |= attr_bold; + if (s.dim) attrs |= attr_dim; + if (s.italic) attrs |= attr_italic; + if (s.blink) attrs |= attr_blink; + if (s.reverse) attrs |= attr_reverse; + if (s.invisible) attrs |= attr_invisible; + if (s.strikethrough) attrs |= attr_strikethrough; + try w.putByte(attrs); + try putUl(w, s.ul); + try putFont(w, s.font_role); +} + +fn decodeCell(r: *Reader) Error!pardes.Cell { + if (try r.getBool()) return .{}; + var c: pardes.Cell = .{ .default = false }; + const len = try r.getByte(); + // `Cell.text` is 7 bytes and `grapheme()` slices to `len`. A zero-length + // grapheme is a cell with nothing to draw and no way to advance a column. + if (len == 0 or len > c.text.len) return error.BadValue; + c.len = len; + @memcpy(c.text[0..len], try r.take(len)); + c.style.fg = try getColor(r); + c.style.bg = try getColor(r); + const attrs = try r.getByte(); + if (attrs & attr_reserved != 0) return error.BadValue; + c.style.bold = attrs & attr_bold != 0; + c.style.dim = attrs & attr_dim != 0; + c.style.italic = attrs & attr_italic != 0; + c.style.blink = attrs & attr_blink != 0; + c.style.reverse = attrs & attr_reverse != 0; + c.style.invisible = attrs & attr_invisible != 0; + c.style.strikethrough = attrs & attr_strikethrough != 0; + c.style.ul = try getUl(r); + c.style.font_role = try getFont(r); + return c; +} + +// --------------------------------------------------------------------------- +// frames +// --------------------------------------------------------------------------- + +fn putCursor(w: *Writer, cursor: ?Cursor, cols: u16, rows: u16) Error!void { + // Fixed six bytes whether or not there is a cursor. An optional field + // would save five bytes on a message that is already hundreds, and cost a + // branch on both sides of the wire. + try w.putBool(cursor != null); + const c = cursor orelse Cursor{ .x = 0, .y = 0, .bar = false }; + // Refused here as well as on decode, so this side cannot build a frame its + // own decoder rejects: a dropped frame (sendFrame logs it) leaves a stale + // screen, and a refused one takes the frontend's connection with it. + if (c.x >= cols or c.y >= rows) return error.BadValue; + try w.putU16(c.x); + try w.putU16(c.y); + try w.putBool(c.bar); +} + +fn getCursor(r: *Reader, cols: u16, rows: u16) Error!?Cursor { + const present = try r.getBool(); + const c: Cursor = .{ .x = try r.getU16(), .y = try r.getU16(), .bar = try r.getBool() }; + // THE cursor bounds check. Every other field of a frame is checked against + // the grid and these two were not, and they are the two a frontend indexes + // with directly — `Surface.at(cursor.x, cursor.y)` on a grid this frame + // says is 4x2 is an out-of-bounds write in EVERY frontend, not a wrong + // glyph. Checked whether or not the cursor is present, because the absent + // case is six bytes of padding this encoder writes as zero and a peer that + // fills them with anything else is not speaking this protocol. + if (c.x >= cols or c.y >= rows) return error.BadValue; + return if (present) c else null; +} + +/// Encode one frame of `cells`, as a DIFF against `prev` when `prev` is the +/// same grid this receiver last acknowledged, and as a FULL frame otherwise. +/// +/// A late joiner and a resize are the same case and are handled by the same +/// test: nothing on the far side is comparable to this grid, so `prev` is +/// empty or a different length and the frame becomes full. +/// +/// Why a diff at all. Measured against a real `pardes --detach` at 80x24 with +/// the default theme, which paints every cell and gives most of them an rgb +/// pair (14 bytes a cell rather than the 8 an uncoloured one costs): +/// * a full frame is 21965-26699 bytes, depending on what is on screen, +/// * a frame that changes nothing is 20 — the head, and no runs at all, +/// * a one-row change (`Msg hi`) is 54 bytes in one run, and a wordier one +/// 166, +/// * and an IDLE session sends nothing whatever, because the core is asleep +/// in `poll(2)` and produces no frame until something happens. +/// So the diff is worth roughly 500x on the traffic a session actually +/// generates. The byte-count test below asserts the arithmetic on the board's +/// own 56x14 grid with uncoloured cells, where it is exact: 6298 against 56. +/// This transport exists for a frontend on the far end of a slow link, and +/// shipping the Surface every frame would be 3 MiB/s at the animation tick. +pub fn encodeFrame( + out: []u8, + cols: u16, + rows: u16, + cursor: ?Cursor, + cells: []const pardes.Cell, + prev: []const pardes.Cell, +) Error![]const u8 { + // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the + // assert below so a caller can be told rather than tripped. `max_payload` + // is derived from these two, so a larger grid is a frame this decoder + // refuses as Overlong — and sendFrame's log line already claims that this + // is the error it is catching, which was true of nothing until here. + if (cols == 0 or cols > max_cols or rows == 0 or rows > max_rows) return error.Overlong; + std.debug.assert(cells.len == @as(usize, cols) * @as(usize, rows)); + const full = prev.len != cells.len; + var w: Writer = .init(out); + const at = try beginMessage(&w, @intFromEnum(ServerTag.frame)); + try w.putByte(@intFromEnum(@as(FrameKind, if (full) .full else .diff))); + try w.putU16(cols); + try w.putU16(rows); + try putCursor(&w, cursor, cols, rows); + const nruns_at = w.n; + try w.putU32(0); + + var nruns: u32 = 0; + var i: usize = 0; + while (i < cells.len) { + if (!sendCell(cells, prev, full, i)) { + i += 1; + continue; + } + const start = i; + var run_end = i + 1; + i += 1; + while (i < cells.len) { + if (sendCell(cells, prev, full, i)) { + run_end = i + 1; + i += 1; + continue; + } + // A gap. Re-sending cells the far side already has is cheaper than + // a second run header whenever their encoded size adds up to less + // than one — true for short stretches of unpainted cells at a byte + // each, and false as soon as one painted cell (eight bytes at its + // smallest) is in the way. So the lookahead can never need to pass + // `run_header - 1` cells. + var gap: usize = 0; + var j = i; + while (j < cells.len and gap < run_header) : (j += 1) { + if (sendCell(cells, prev, full, j)) break; + gap += cellSize(&cells[j]); + } + if (j >= cells.len or gap >= run_header) break; + i = j; + } + try w.putU32(@intCast(start)); + try w.putU16(@intCast(run_end - start)); + for (cells[start..run_end]) |*c| try putCell(&w, c); + nruns += 1; + i = run_end; + } + std.mem.writeInt(u32, w.buf[nruns_at..][0..4], nruns, .little); + try finishMessage(&w, at); + return w.written(); +} + +fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i: usize) bool { + // Full: the receiver reset the grid, so unpainted cells are already right. + // Diff: anything the receiver cannot already be showing. + if (full) return !cells[i].default; + return !cells[i].visuallyEqual(&prev[i]); +} + +// --------------------------------------------------------------------------- +// frontend -> core +// --------------------------------------------------------------------------- + +/// Which tag a message travels under: the `ClientTag` of the same NAME, so a +/// new `Event` variant does not compile until it has a number here. See +/// `putUl` for the error it produces and why this is not a written-out table. +fn clientTag(msg: ClientMsg) ClientTag { + return switch (msg) { + .event => |ev| switch (ev) { + // Not on the wire, and not an omission: see the module header. + // The acme mount lives with the core, so this event is raised in + // the same process that answers it and never crosses a socket. + .fs_req => unreachable, + inline else => |_, t| @field(ClientTag, @tagName(t)), + }, + inline else => |_, t| @field(ClientTag, @tagName(t)), + }; +} + +/// Does the build this frontend was compiled with carry pixel dimensions in a +/// resize? The FIELD is comptime-conditional (pardes.zig `CellPixels`); the +/// WIRE is not. +const has_cell_pixels = @hasField(pardes.CellPixels, "w"); + +pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { + var w: Writer = .init(out); + const at = try beginMessage(&w, @intFromEnum(clientTag(msg))); + switch (msg) { + .hello => |h| { + try w.putU16(h.version); + try w.putU16(h.cols); + try w.putU16(h.rows); + }, + .bye => {}, + .event => |ev| switch (ev) { + .key => |k| { + try w.putU32(k.cp); + try w.putSlice16(k.text); + try w.putBool(k.ctrl); + try w.putBool(k.alt); + try w.putBool(k.shift); + }, + .mouse => |m| { + try w.putByte(switch (m.button) { + inline else => |t| @intFromEnum(@field(ButtonTag, @tagName(t))), + }); + try w.putByte(switch (m.kind) { + inline else => |t| @intFromEnum(@field(KindTag, @tagName(t))), + }); + try w.putU16(m.col); + try w.putU16(m.row); + try w.putBool(m.ctrl); + }, + .resize => |rs| { + try w.putU16(rs.cols); + try w.putU16(rs.rows); + // The conventional 1:2 cell aspect when this build has no + // pixels of its own, which is the same default the field + // carries where it exists. + try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.w else 8); + try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.h else 16); + }, + .output => |o| { + try w.putByte(o.pane); + try w.putSlice32(o.bytes); + }, + .eof => |e| try w.putByte(e.pane), + .lsp_resp => |l| { + try w.putU32(l.id); + try w.putSlice32(l.rows); + }, + .pipe_resp => |p| { + try w.putU32(p.id); + try w.putBool(p.success); + if (p.outputs.len > pardes.MAX_SELS) return error.Overlong; + try w.putU16(@intCast(p.outputs.len)); + for (p.outputs) |o| try w.putSlice32(o); + }, + .file_changed => |f| { + try w.putByte(f.pane); + try w.putSlice32(f.bytes); + }, + .paste => |b| try w.putSlice32(b), + .command => |line| try w.putSlice16(line), + .pdf_scroll => |s| { + try w.putByte(s.pane); + try w.putF32(s.delta_pixels); + }, + .pinch => |v| try w.putF32(v), + .touch_scroll => |v| try w.putF32(v), + .pointer_leave, .tick => {}, + .fs_req => unreachable, + }, + } + try finishMessage(&w, at); + return w.written(); +} + +pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!ClientMsg { + var r: Reader = .init(payload); + const msg: ClientMsg = switch (std.enums.fromInt(ClientTag, tag) orelse return error.BadTag) { + .hello => .{ .hello = .{ + .version = try r.getU16(), + .cols = try r.getCols(), + .rows = try r.getRows(), + } }, + .bye => .bye, + .key => blk: { + const cp = try r.getU32(); + // `Key.cp` is a u21, and the specials live in the private-use + // plane below this bound. A larger number is not a codepoint and + // @intCast of it would panic in a release build's own decoder. + if (cp > 0x10FFFF) return error.BadValue; + break :blk .{ .event = .{ .key = .{ + .cp = @intCast(cp), + .text = try r.getSlice16(), + .ctrl = try r.getBool(), + .alt = try r.getBool(), + .shift = try r.getBool(), + } } }; + }, + .mouse => .{ .event = .{ .mouse = .{ + .button = switch (try r.getTag(ButtonTag)) { + inline else => |t| @field(pardes.Mouse.Button, @tagName(t)), + }, + .kind = switch (try r.getTag(KindTag)) { + inline else => |t| @field(pardes.Mouse.Kind, @tagName(t)), + }, + .col = try r.getU16(), + .row = try r.getU16(), + .ctrl = try r.getBool(), + } } }, + .resize => blk: { + var ev: pardes.Event = .{ .resize = .{ .cols = try r.getCols(), .rows = try r.getRows() } }; + const px_w = try r.getU16(); + const px_h = try r.getU16(); + // Read either way — the bytes are on the wire — and kept only by a + // build that has somewhere to keep them. + if (comptime has_cell_pixels) ev.resize.cell_pixels = .{ .w = px_w, .h = px_h }; + break :blk .{ .event = ev }; + }, + .output => .{ .event = .{ .output = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, + .eof => .{ .event = .{ .eof = .{ .pane = try r.getPane() } } }, + .lsp_resp => .{ .event = .{ .lsp_resp = .{ .id = try r.getU32(), .rows = try r.getSlice32() } } }, + .pipe_resp => blk: { + const id = try r.getU32(); + const success = try r.getBool(); + const n = try r.getU16(); + if (n > scratch.outputs.len) return error.Overlong; + for (scratch.outputs[0..n]) |*o| o.* = try r.getSlice32(); + break :blk .{ .event = .{ .pipe_resp = .{ + .id = id, + .success = success, + .outputs = scratch.outputs[0..n], + } } }; + }, + .file_changed => .{ .event = .{ .file_changed = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, + .paste => .{ .event = .{ .paste = try r.getSlice32() } }, + .command => .{ .event = .{ .command = try r.getSlice16() } }, + .pdf_scroll => .{ .event = .{ .pdf_scroll = .{ .pane = try r.getPane(), .delta_pixels = try r.getF32() } } }, + .pinch => .{ .event = .{ .pinch = try r.getF32() } }, + .touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } }, + .pointer_leave => .{ .event = .pointer_leave }, + .tick => .{ .event = .tick }, + }; + try r.end(); + return msg; +} + +// --------------------------------------------------------------------------- +// core -> frontend +// --------------------------------------------------------------------------- + +/// ...and the same rule in the same shape: the `ServerTag` of the same name. +fn serverTag(msg: ServerMsg) ServerTag { + return switch (msg) { + inline else => |_, t| @field(ServerTag, @tagName(t)), + }; +} + +/// Every server message EXCEPT a frame, which has its own encoder because its +/// payload is a walk of two grids rather than a value (see `encodeFrame`). +pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 { + var w: Writer = .init(out); + const at = try beginMessage(&w, @intFromEnum(serverTag(msg))); + switch (msg) { + .welcome => |v| { + try w.putU16(v.version); + try w.putByte(v.slot); + try w.putU16(v.cols); + try w.putU16(v.rows); + }, + .refuse => |why| try w.putByte(@intFromEnum(why)), + // A frame's runs are not a value this union can hold; the server calls + // encodeFrame directly and this arm exists so the switch stays + // exhaustive over ServerMsg. + .frame => return error.BadValue, + .quit => {}, + .spawn => |s| { + try w.putByte(s.pane); + try w.putSlice16(s.cwd); + }, + .pty_write => |p| { + try w.putByte(p.pane); + try w.putSlice32(p.bytes); + }, + .pty_resize => |p| { + try w.putByte(p.pane); + try w.putU16(p.cols); + try w.putU16(p.rows); + }, + .write_file => |f| { + try w.putByte(f.pane); + try w.putSlice16(f.path); + try w.putSlice32(f.bytes); + }, + .write_dump => |b| try w.putSlice32(b), + .watch_file => |v| { + try w.putByte(v.pane); + try w.putSlice16(v.path); + try w.putBool(v.on); + }, + .watch_theme => |t| { + try w.putU32(t.generation); + try w.putBool(t.on); + }, + .dump_themes => |d| try w.putByte(d.pane), + .set_clipboard => |t| try w.putSlice32(t), + .read_clipboard => {}, + .open_link => |u| try w.putSlice16(u), + } + try finishMessage(&w, at); + return w.written(); +} + +/// Slack over a message's variable payload, covering every fixed field any +/// message here has plus its own header. One loose constant rather than a +/// field-by-field count: a bound that is 32 bytes generous costs one `memcpy` +/// worth of nothing, and a bound that is one byte tight is a bug that only +/// shows up on the message nobody tested. +const msg_slack = header_len + 32; + +/// An upper bound on `encodeServer`'s output, so a caller sizes its buffer +/// once instead of guessing and retrying. +pub fn serverBound(msg: ServerMsg) usize { + return msg_slack + switch (msg) { + .welcome, .refuse, .quit, .pty_resize, .watch_theme, .dump_themes, .read_clipboard => 0, + // A frame is bounded by its grid, not by this: see `frameBound`. + .frame => |f| frameBound(f.cols, f.rows), + .spawn => |s| s.cwd.len, + .pty_write => |p| p.bytes.len, + .write_file => |f| f.path.len + f.bytes.len, + .write_dump => |b| b.len, + .watch_file => |v| v.path.len, + .set_clipboard => |t| t.len, + .open_link => |u| u.len, + }; +} + +/// ...and the same for the frontend's side of the wire. +pub fn clientBound(msg: ClientMsg) usize { + return msg_slack + switch (msg) { + .hello, .bye => 0, + .event => |ev| switch (ev) { + .mouse, .resize, .eof, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .tick => 0, + .key => |k| k.text.len, + .output => |o| o.bytes.len, + .lsp_resp => |l| l.rows.len, + .pipe_resp => |p| blk: { + // Each output carries its own u32 prefix, so the count is part + // of the bound and not just the bytes. + var total: usize = p.outputs.len * 4; + for (p.outputs) |o| total += o.len; + break :blk total; + }, + .file_changed => |f| f.bytes.len, + .paste => |b| b.len, + .command => |line| line.len, + .fs_req => unreachable, + }, + }; +} + +pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { + var r: Reader = .init(payload); + const msg: ServerMsg = switch (std.enums.fromInt(ServerTag, tag) orelse return error.BadTag) { + .welcome => .{ .welcome = .{ + .version = try r.getU16(), + .slot = try r.getByte(), + .cols = try r.getCols(), + .rows = try r.getRows(), + } }, + .refuse => .{ .refuse = try r.getTag(Refusal) }, + .frame => blk: { + const kind = try r.getTag(FrameKind); + const cols = try r.getCols(); + const rows = try r.getRows(); + const cursor = try getCursor(&r, cols, rows); + const nruns = try r.getU32(); + // One run per cell is the worst an encoder emits, so a bigger + // count cannot be describing this grid. Checked HERE rather than + // in `apply`'s loop so the number is refused before it is used to + // bound anything. + if (nruns > @as(u32, cols) * @as(u32, rows)) return error.Overlong; + const runs = r.bytes[r.i..]; + r.i = r.bytes.len; + break :blk .{ .frame = .{ + .kind = kind, + .cols = cols, + .rows = rows, + .cursor = cursor, + .nruns = nruns, + .runs = runs, + } }; + }, + .quit => .quit, + .spawn => .{ .spawn = .{ .pane = try r.getPane(), .cwd = try r.getSlice16() } }, + .pty_write => .{ .pty_write = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } }, + .pty_resize => .{ .pty_resize = .{ + .pane = try r.getPane(), + .cols = try r.getU16(), + .rows = try r.getU16(), + } }, + .write_file => .{ .write_file = .{ + .pane = try r.getPane(), + .path = try r.getSlice16(), + .bytes = try r.getSlice32(), + } }, + .write_dump => .{ .write_dump = try r.getSlice32() }, + .watch_file => .{ .watch_file = .{ + .pane = try r.getPane(), + .path = try r.getSlice16(), + .on = try r.getBool(), + } }, + .watch_theme => .{ .watch_theme = .{ .generation = try r.getU32(), .on = try r.getBool() } }, + .dump_themes => .{ .dump_themes = .{ .pane = try r.getPane() } }, + .set_clipboard => .{ .set_clipboard = try r.getSlice32() }, + .read_clipboard => .read_clipboard, + .open_link => .{ .open_link = try r.getSlice16() }, + }; + try r.end(); + return msg; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- +// +// Two obligations. Every message round-trips to a deep-equal value, because a +// codec written by hand is a codec whose two halves drift; and every malformed +// shape is REFUSED, because this parser is fed by a socket and a frame that is +// misread rather than refused is an out-of-bounds index. + +const testing = std.testing; + +/// Round-trip one frontend -> core message through the framing too, so a +/// length prefix that disagrees with the payload cannot pass. +fn roundClient(buf: []u8, msg: ClientMsg, scratch: *Scratch) !ClientMsg { + const bytes = try encodeClient(buf, msg); + const f = (try framed(bytes)).?; + try testing.expectEqual(bytes.len, f.total); + return decodeClient(f.tag, f.payload, scratch); +} + +fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg { + const bytes = try encodeServer(buf, msg); + const f = (try framed(bytes)).?; + try testing.expectEqual(bytes.len, f.total); + return decodeServer(f.tag, f.payload); +} + +test "detached wire: every Event variant round-trips" { + var buf: [4096]u8 = undefined; + var scratch: Scratch = .{}; + + // The tag space is the protocol's own, so assert the numbers themselves: + // a renumbering here breaks every deployed frontend and must be a diff + // somebody reads, not a silent change. + try testing.expectEqual(@as(u8, 0x01), @intFromEnum(ClientTag.hello)); + try testing.expectEqual(@as(u8, 0x10), @intFromEnum(ClientTag.key)); + try testing.expectEqual(@as(u8, 0x1e), @intFromEnum(ClientTag.tick)); + + { + const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }, &scratch); + try testing.expectEqual(version, got.hello.version); + try testing.expectEqual(@as(u16, 80), got.hello.cols); + try testing.expectEqual(@as(u16, 24), got.hello.rows); + } + try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye, &scratch)); + + { + const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true }; + const got = (try roundClient(&buf, .{ .event = .{ .key = key } }, &scratch)).event.key; + try testing.expectEqual(key.cp, got.cp); + try testing.expectEqualStrings(key.text, got.text); + try testing.expectEqual(key.ctrl, got.ctrl); + try testing.expectEqual(key.alt, got.alt); + try testing.expectEqual(key.shift, got.shift); + } + { + // Every button and every kind, because the two mapping switches are + // the only place a value can be mistranslated and still decode. + for (std.enums.values(pardes.Mouse.Button)) |button| { + for (std.enums.values(pardes.Mouse.Kind)) |kind| { + const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true }; + const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } }, &scratch)).event.mouse; + try testing.expectEqual(m.button, got.button); + try testing.expectEqual(m.kind, got.kind); + try testing.expectEqual(m.col, got.col); + try testing.expectEqual(m.row, got.row); + try testing.expectEqual(m.ctrl, got.ctrl); + } + } + } + { + const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } }, &scratch)).event.resize; + try testing.expectEqual(@as(u16, 56), got.cols); + try testing.expectEqual(@as(u16, 14), got.rows); + // Pixels travel whether or not this build has them; where it does, the + // conventional 1:2 default survives the trip. + if (comptime has_cell_pixels) { + try testing.expectEqual(@as(u16, 8), got.cell_pixels.w); + try testing.expectEqual(@as(u16, 16), got.cell_pixels.h); + } + } + { + const got = (try roundClient(&buf, .{ .event = .{ .output = .{ .pane = 3, .bytes = "hi\x00there" } } }, &scratch)).event.output; + try testing.expectEqual(@as(u8, 3), got.pane); + try testing.expectEqualStrings("hi\x00there", got.bytes); + } + try testing.expectEqual(@as(u8, 15), (try roundClient(&buf, .{ .event = .{ .eof = .{ .pane = 15 } } }, &scratch)).event.eof.pane); + { + const got = (try roundClient(&buf, .{ .event = .{ .lsp_resp = .{ .id = 0xdeadbeef, .rows = "a:1:2-3 x" } } }, &scratch)).event.lsp_resp; + try testing.expectEqual(@as(u32, 0xdeadbeef), got.id); + try testing.expectEqualStrings("a:1:2-3 x", got.rows); + } + { + // Including an EMPTY output, which is what a filter that consumed a + // selection and printed nothing returns. + const outputs: []const []const u8 = &.{ "AA\n", "", "cc" }; + const got = (try roundClient(&buf, .{ .event = .{ .pipe_resp = .{ .id = 9, .success = true, .outputs = outputs } } }, &scratch)).event.pipe_resp; + try testing.expectEqual(@as(u32, 9), got.id); + try testing.expect(got.success); + try testing.expectEqual(@as(usize, 3), got.outputs.len); + for (outputs, got.outputs) |want, have| try testing.expectEqualStrings(want, have); + } + { + const got = (try roundClient(&buf, .{ .event = .{ .file_changed = .{ .pane = 0, .bytes = "" } } }, &scratch)).event.file_changed; + try testing.expectEqual(@as(u8, 0), got.pane); + try testing.expectEqualStrings("", got.bytes); + } + try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } }, &scratch)).event.paste); + try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } }, &scratch)).event.command); + { + const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } }, &scratch)).event.pdf_scroll; + try testing.expectEqual(@as(u8, 2), got.pane); + try testing.expectEqual(@as(f32, -12.5), got.delta_pixels); + } + try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } }, &scratch)).event.pinch); + try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } }, &scratch)).event.touch_scroll); + try testing.expectEqual( + std.meta.Tag(pardes.Event).pointer_leave, + (try roundClient(&buf, .{ .event = .pointer_leave }, &scratch)).event, + ); + try testing.expectEqual( + std.meta.Tag(pardes.Event).tick, + (try roundClient(&buf, .{ .event = .tick }, &scratch)).event, + ); +} + +test "detached wire: every server message round-trips" { + var buf: [4096]u8 = undefined; + + { + const got = (try roundServer(&buf, .{ .welcome = .{ .slot = 2, .cols = 56, .rows = 14 } })).welcome; + try testing.expectEqual(version, got.version); + try testing.expectEqual(@as(u8, 2), got.slot); + try testing.expectEqual(@as(u16, 56), got.cols); + try testing.expectEqual(@as(u16, 14), got.rows); + } + for (std.enums.values(Refusal)) |why| + try testing.expectEqual(why, (try roundServer(&buf, .{ .refuse = why })).refuse); + try testing.expectEqual(ServerMsg.quit, try roundServer(&buf, .quit)); + { + const got = (try roundServer(&buf, .{ .spawn = .{ .pane = 1, .cwd = "/home/x" } })).spawn; + try testing.expectEqual(@as(u8, 1), got.pane); + try testing.expectEqualStrings("/home/x", got.cwd); + } + { + const got = (try roundServer(&buf, .{ .pty_write = .{ .pane = 1, .bytes = "ls\r" } })).pty_write; + try testing.expectEqual(@as(u8, 1), got.pane); + try testing.expectEqualStrings("ls\r", got.bytes); + } + { + const got = (try roundServer(&buf, .{ .pty_resize = .{ .pane = 1, .cols = 80, .rows = 24 } })).pty_resize; + try testing.expectEqual(@as(u16, 80), got.cols); + try testing.expectEqual(@as(u16, 24), got.rows); + } + { + const got = (try roundServer(&buf, .{ .write_file = .{ .pane = 4, .path = "/tmp/a", .bytes = "body\n" } })).write_file; + try testing.expectEqual(@as(u8, 4), got.pane); + try testing.expectEqualStrings("/tmp/a", got.path); + try testing.expectEqualStrings("body\n", got.bytes); + } + try testing.expectEqualStrings(".{}", (try roundServer(&buf, .{ .write_dump = ".{}" })).write_dump); + { + const got = (try roundServer(&buf, .{ .watch_file = .{ .pane = 0, .path = "/tmp/b", .on = true } })).watch_file; + try testing.expectEqualStrings("/tmp/b", got.path); + try testing.expect(got.on); + } + { + const got = (try roundServer(&buf, .{ .watch_theme = .{ .generation = 7, .on = false } })).watch_theme; + try testing.expectEqual(@as(u32, 7), got.generation); + try testing.expect(!got.on); + } + try testing.expectEqual(@as(u8, 5), (try roundServer(&buf, .{ .dump_themes = .{ .pane = 5 } })).dump_themes.pane); + try testing.expectEqualStrings("yank", (try roundServer(&buf, .{ .set_clipboard = "yank" })).set_clipboard); + try testing.expectEqual(ServerMsg.read_clipboard, try roundServer(&buf, .read_clipboard)); + try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link); +} + +/// A grid with something in every corner: a default cell, a plain ASCII cell, +/// an indexed pair, an rgb pair with every attribute on, and a multi-byte +/// grapheme — the five shapes `putCell` branches on. +fn sampleGrid(cells: []pardes.Cell) void { + @memset(cells, .{}); + cells[0] = .{ .text = "x".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[1] = .{ + .text = "y".* ++ @as([6]u8, @splat(0)), + .len = 1, + .default = false, + .style = .{ .fg = .{ .index = 3 }, .bg = .{ .index = 250 }, .bold = true, .ul = .curly }, + }; + cells[2] = .{ + .text = "→".* ++ @as([4]u8, @splat(0)), + .len = 3, + .default = false, + .style = .{ + .fg = .{ .rgb = .{ 1, 2, 3 } }, + .bg = .{ .rgb = .{ 250, 251, 252 } }, + .bold = true, + .dim = true, + .italic = true, + .blink = true, + .reverse = true, + .invisible = true, + .strikethrough = true, + .ul = .dashed, + .font_role = .tagline, + }, + }; + cells[cells.len - 1] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; +} + +fn expectGridEqual(want: []const pardes.Cell, have: []const pardes.Cell) !void { + try testing.expectEqual(want.len, have.len); + // `visuallyEqual` and not `std.meta.eql`: `Cell.text` past `len` is + // scratch left by an earlier grapheme, the decoder does not invent it, and + // pardes.zig says in as many words that it must never manufacture a diff. + for (want, have, 0..) |*a, *b, i| if (!a.visuallyEqual(b)) { + std.debug.print("cell {d} differs: {any} vs {any}\n", .{ i, a.*, b.* }); + return error.CellMismatch; + }; +} + +test "detached wire: a full frame carries the grid, a diff carries the change" { + const cols: u16 = 56; + const rows: u16 = 14; + const n = @as(usize, cols) * rows; + const gpa = testing.allocator; + + const cells = try gpa.alloc(pardes.Cell, n); + defer gpa.free(cells); + const mirror = try gpa.alloc(pardes.Cell, n); + defer gpa.free(mirror); + const buf = try gpa.alloc(u8, frameBound(cols, rows)); + defer gpa.free(buf); + + sampleGrid(cells); + @memset(mirror, .{}); + + // FULL: nothing on the far side is comparable, which is the late joiner + // and the resize both. + const full_bytes = try encodeFrame(buf, cols, rows, .{ .x = 3, .y = 4, .bar = true }, cells, &.{}); + { + const f = (try framed(full_bytes)).?; + const msg = (try decodeServer(f.tag, f.payload)).frame; + try testing.expectEqual(FrameKind.full, msg.kind); + try testing.expectEqual(cols, msg.cols); + try testing.expectEqual(rows, msg.rows); + try testing.expectEqual(@as(u16, 3), msg.cursor.?.x); + try testing.expectEqual(@as(u16, 4), msg.cursor.?.y); + try testing.expect(msg.cursor.?.bar); + try msg.apply(mirror); + try expectGridEqual(cells, mirror); + } + + // DIFF: two cells move. The mirror is already in sync, so this is what a + // steady-state frame looks like. + const before = try gpa.dupe(pardes.Cell, cells); + defer gpa.free(before); + cells[100] = .{ .text = "q".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[0] = .{}; // ...and one goes back to unpainted, which a diff must say + const diff_bytes = try encodeFrame(buf, cols, rows, null, cells, before); + { + const f = (try framed(diff_bytes)).?; + const msg = (try decodeServer(f.tag, f.payload)).frame; + try testing.expectEqual(FrameKind.diff, msg.kind); + try testing.expectEqual(@as(?Cursor, null), msg.cursor); + try testing.expectEqual(@as(u32, 2), msg.nruns); + try msg.apply(mirror); + try expectGridEqual(cells, mirror); + } + + // An unchanged frame is the head and nothing else: no runs, and the + // receiver keeps what it has. This is what makes an idle session silent. + { + const idle = try encodeFrame(buf, cols, rows, null, cells, cells); + try testing.expectEqual(@as(usize, header_len + frame_head), idle.len); + const f = (try framed(idle)).?; + const msg = (try decodeServer(f.tag, f.payload)).frame; + try testing.expectEqual(@as(u32, 0), msg.nruns); + try msg.apply(mirror); + try expectGridEqual(cells, mirror); + } +} + +test "detached wire: the diff is worth having, in bytes, on the board's grid" { + // The numbers quoted in `encodeFrame`'s comment, asserted so the claim + // cannot rot. 56x14 is the ESP32-P4 board's default grid (esp32p4.zig). + const cols: u16 = 56; + const rows: u16 = 14; + const n = @as(usize, cols) * rows; + const gpa = testing.allocator; + + const cells = try gpa.alloc(pardes.Cell, n); + defer gpa.free(cells); + const buf = try gpa.alloc(u8, frameBound(cols, rows)); + defer gpa.free(buf); + + // Every cell painted with a plain ASCII glyph and default colors, which is + // what a pardes frame overwhelmingly is: eight bytes a cell. + for (cells) |*c| c.* = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + const full = (try encodeFrame(buf, cols, rows, null, cells, &.{})).len; + try testing.expectEqual(@as(usize, header_len + frame_head + run_header + n * 8), full); + try testing.expectEqual(@as(usize, 6298), full); + + const before = try gpa.dupe(pardes.Cell, cells); + defer gpa.free(before); + // A keystroke: one glyph replaced, and the cursor's old and new cells + // repainted. Three cells, adjacent enough to coalesce into two runs. + cells[300] = .{ .text = "b".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[301] = .{ .text = "c".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[500] = .{ .text = "d".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + const diff = (try encodeFrame(buf, cols, rows, null, cells, before)).len; + try testing.expectEqual(@as(usize, header_len + frame_head + 2 * run_header + 3 * 8), diff); + try testing.expectEqual(@as(usize, 56), diff); + // The whole reason this codec exists rather than shipping the Surface. + try testing.expect(full / diff > 100); +} + +test "detached wire: a run is coalesced across a gap only when that is cheaper" { + const cols: u16 = 8; + const rows: u16 = 1; + var cells: [8]pardes.Cell = @splat(.{}); + var prev: [8]pardes.Cell = @splat(.{}); + var buf: [512]u8 = undefined; + + // Two changes with three UNPAINTED cells between them. Re-sending those is + // three bytes; a second run header is six. One run. + cells[0] = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[4] = .{ .text = "b".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + { + const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?; + try testing.expectEqual(@as(u32, 1), (try decodeServer(f.tag, f.payload)).frame.nruns); + } + // Now put a PAINTED cell in the gap that both sides already agree about. + // Eight bytes to re-send against six for a header: two runs. + const painted: pardes.Cell = .{ .text = "-".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; + cells[2] = painted; + prev[2] = painted; + { + const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?; + try testing.expectEqual(@as(u32, 2), (try decodeServer(f.tag, f.payload)).frame.nruns); + } + // Whichever it chose, the receiver ends up with the same grid. + var mirror: [8]pardes.Cell = prev; + const f = (try framed(try encodeFrame(&buf, cols, rows, null, &cells, &prev))).?; + try (try decodeServer(f.tag, f.payload)).frame.apply(&mirror); + try expectGridEqual(&cells, &mirror); +} + +test "detached wire: a truncated frame is refused at every length" { + var buf: [4096]u8 = undefined; + var scratch: Scratch = .{}; + + // Every prefix of a real message. The framing must say "not yet" for the + // ones that are short, and the decoder must say "truncated" for a payload + // whose header lies about its length — never read past the slice. + const whole = try encodeClient(&buf, .{ .event = .{ .key = .{ .cp = 'a', .text = "a" } } }); + var copy: [64]u8 = undefined; + @memcpy(copy[0..whole.len], whole); + for (header_len + 1..whole.len) |cut| { + try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut])); + // ...and the same bytes handed to the decoder with the header's length + // left claiming the whole message, which is how a decoder is walked + // off the end of its buffer. + try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut], &scratch)); + } + // Shorter than the header itself is not yet a message at all. + for (0..header_len + 1) |cut| + try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut])); + // A payload with bytes LEFT OVER is refused too: it is not this message. + try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.tick), "x", &scratch)); + try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x")); +} + +test "detached wire: an unknown tag is refused, never guessed" { + var scratch: Scratch = .{}; + // 0x00 and 0xff have never been assigned, and 0x0f sits in the gap between + // the session tags and the input tags. All three are the same answer. + for ([_]u8{ 0x00, 0x0f, 0x1f, 0xff }) |tag| { + try testing.expectError(error.BadTag, decodeClient(tag, "", &scratch)); + try testing.expectError(error.BadTag, decodeServer(tag, "")); + } + // A tag NESTED in a payload gets the same treatment: a color, an + // underline style, a mouse button, a refusal reason. + try testing.expectError(error.BadTag, decodeServer(@intFromEnum(ServerTag.refuse), &.{0x7f})); + try testing.expectError(error.BadTag, decodeClient( + @intFromEnum(ClientTag.mouse), + &.{ 0x09, 0x00, 0, 0, 0, 0, 0 }, + &scratch, + )); +} + +test "detached wire: an over-long length prefix is refused before it is believed" { + // The prefix a hostile or corrupt peer sends to make the receiver allocate + // or index by it. `framed` must refuse rather than wait for 4 GiB. + var head: [header_len]u8 = .{ @intFromEnum(ClientTag.paste), 0, 0, 0, 0 }; + std.mem.writeInt(u32, head[1..5], max_payload + 1, .little); + try testing.expectError(error.Overlong, framed(&head)); + std.mem.writeInt(u32, head[1..5], std.math.maxInt(u32), .little); + try testing.expectError(error.Overlong, framed(&head)); + // At the boundary it is a legal prefix and simply has not all arrived. + std.mem.writeInt(u32, head[1..5], max_payload, .little); + try testing.expectEqual(@as(?Framed, null), try framed(&head)); + + // An INNER length prefix, past the payload it sits in but inside the + // protocol's cap — the one an outer-frame check cannot catch. + var scratch: Scratch = .{}; + var paste: [8]u8 = undefined; + std.mem.writeInt(u32, paste[0..4], 4096, .little); + @memcpy(paste[4..8], "abcd"); + try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); + // ...and past the cap, which is refused rather than read. + std.mem.writeInt(u32, paste[0..4], max_payload + 1, .little); + try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); +} + +test "detached wire: a frame that lies about its runs cannot walk out of the grid" { + const cols: u16 = 4; + const rows: u16 = 2; + var grid: [8]pardes.Cell = @splat(.{}); + + // start = 6, count = 4 on an 8-cell grid: the check that matters, and the + // one an `start + count > len` spelling would miss on overflow. + var runs: [10]u8 = undefined; + std.mem.writeInt(u32, runs[0..4], 6, .little); + std.mem.writeInt(u16, runs[4..6], 4, .little); + runs[6] = 1; + const f: Frame = .{ .kind = .diff, .cols = cols, .rows = rows, .cursor = null, .nruns = 1, .runs = runs[0..7] }; + try testing.expectError(error.Overlong, f.apply(&grid)); + + // A start past the end entirely, and the 32-bit wrap. + std.mem.writeInt(u32, runs[0..4], std.math.maxInt(u32) - 1, .little); + std.mem.writeInt(u16, runs[4..6], 4, .little); + try testing.expectError(error.Overlong, (Frame{ + .kind = .diff, + .cols = cols, + .rows = rows, + .cursor = null, + .nruns = 1, + .runs = runs[0..7], + }).apply(&grid)); + + // A zero-length run says nothing and is not something the encoder emits. + std.mem.writeInt(u32, runs[0..4], 0, .little); + std.mem.writeInt(u16, runs[4..6], 0, .little); + try testing.expectError(error.BadValue, (Frame{ + .kind = .diff, + .cols = cols, + .rows = rows, + .cursor = null, + .nruns = 1, + .runs = runs[0..6], + }).apply(&grid)); + + // A run count larger than the grid has cells is refused at DECODE, before + // it is used to bound the walk. + var head: [frame_head]u8 = undefined; + var w: Writer = .init(&head); + try w.putByte(@intFromEnum(FrameKind.diff)); + try w.putU16(cols); + try w.putU16(rows); + try putCursor(&w, null, cols, rows); + try w.putU32(9); + try testing.expectError(error.Overlong, decodeServer(@intFromEnum(ServerTag.frame), w.written())); + + // ...and a grid of the wrong size is the receiver's own bug, not a frame + // it should paint half of. + var small: [4]pardes.Cell = @splat(.{}); + try testing.expectError(error.BadValue, (Frame{ + .kind = .full, + .cols = cols, + .rows = rows, + .cursor = null, + .nruns = 0, + .runs = "", + }).apply(&small)); +} + +test "detached wire: a cursor outside the grid is refused, not painted" { + // The one field of a frame a frontend indexes with rather than copies: + // tty.zig moves the terminal's own cursor to `cursor.x`/`cursor.y`, and + // the ESP32-P4 panel writes the cell there. A frame that says 4x2 and puts + // the cursor at (9,0) is an out-of-bounds write in every frontend, so it + // has to be a decode error and not a clamp — a clamped cursor is a wrong + // screen that nobody reports. + const cols: u16 = 4; + const rows: u16 = 2; + var head: [frame_head]u8 = undefined; + for ([_][2]u16{ .{ cols, 0 }, .{ 0, rows }, .{ 0xffff, 0xffff } }) |xy| { + var w: Writer = .init(&head); + try w.putByte(@intFromEnum(FrameKind.diff)); + try w.putU16(cols); + try w.putU16(rows); + // Written by hand: `putCursor` now refuses this too, which is the + // other half of the same fix and is asserted below. + try w.putBool(true); + try w.putU16(xy[0]); + try w.putU16(xy[1]); + try w.putBool(false); + try w.putU32(0); + try testing.expectError( + error.BadValue, + decodeServer(@intFromEnum(ServerTag.frame), w.written()), + ); + } + // The last cell IS in the grid, and a frame carrying it decodes. + { + var w: Writer = .init(&head); + try w.putByte(@intFromEnum(FrameKind.diff)); + try w.putU16(cols); + try w.putU16(rows); + try putCursor(&w, .{ .x = cols - 1, .y = rows - 1, .bar = true }, cols, rows); + try w.putU32(0); + const got = (try decodeServer(@intFromEnum(ServerTag.frame), w.written())).frame; + try testing.expectEqual(@as(u16, cols - 1), got.cursor.?.x); + try testing.expectEqual(@as(u16, rows - 1), got.cursor.?.y); + } + // ...and the ENCODER refuses to build one, so a core bug is a dropped + // frame with a log line rather than a frame every frontend hangs up over. + var cells: [8]pardes.Cell = @splat(.{}); + var buf: [512]u8 = undefined; + try testing.expectError( + error.BadValue, + encodeFrame(&buf, cols, rows, .{ .x = cols, .y = 0, .bar = false }, &cells, &.{}), + ); + // A grid past the protocol's own ceiling is refused by the encoder for the + // same reason: `max_payload` is derived from it, so the decoder would. + try testing.expectError( + error.Overlong, + encodeFrame(&buf, max_cols + 1, 1, null, cells[0..0], &.{}), + ); +} + +test "detached wire: values a field cannot mean are refused" { + var scratch: Scratch = .{}; + + // A bool is 0 or 1. `2` used to be "true" in every hand-written codec that + // ever silently accepted a corrupt stream. + try testing.expectError(error.BadValue, decodeClient( + @intFromEnum(ClientTag.key), + &.{ 'a', 0, 0, 0, 0, 0, 2, 0, 0 }, + &scratch, + )); + // A codepoint past Unicode's last: @intCast into Key.cp's u21 would panic. + try testing.expectError(error.BadValue, decodeClient( + @intFromEnum(ClientTag.key), + &.{ 0x00, 0x00, 0x11, 0x00, 0, 0, 0, 0, 0 }, + &scratch, + )); + // A pane the core cannot index. + try testing.expectError(error.BadValue, decodeClient( + @intFromEnum(ClientTag.eof), + &.{pardes.MAX_PANES}, + &scratch, + )); + // A zero-column grid would collapse a shared session; an over-wide one is + // past what this protocol carries. + try testing.expectError(error.BadValue, decodeClient( + @intFromEnum(ClientTag.hello), + &.{ 1, 0, 0, 0, 24, 0 }, + &scratch, + )); + { + var hello: [6]u8 = undefined; + std.mem.writeInt(u16, hello[0..2], version, .little); + std.mem.writeInt(u16, hello[2..4], max_cols + 1, .little); + std.mem.writeInt(u16, hello[4..6], 24, .little); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello, &scratch)); + } + // A NaN scroll distance. `pinch` multiplies into a zoom the pane keeps. + { + var pinch: [4]u8 = undefined; + std.mem.writeInt(u32, &pinch, @bitCast(std.math.nan(f32)), .little); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch, &scratch)); + std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch, &scratch)); + } + // More pipe outputs than the core has selections to produce them. + { + var head: [7]u8 = undefined; + std.mem.writeInt(u32, head[0..4], 1, .little); + head[4] = 1; + std.mem.writeInt(u16, head[5..7], pardes.MAX_SELS + 1, .little); + try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.pipe_resp), &head, &scratch)); + } + // A cell with the reserved attribute bit set, and one with an impossible + // grapheme length. Both are bytes this protocol has no meaning for. + { + var grid: [1]pardes.Cell = @splat(.{}); + // default=0, len=1, 'x', fg default, bg default, attrs=0x80, ul, font + var runs = [_]u8{ 0, 0, 0, 0, 1, 0, 0, 1, 'x', 0, 0, 0x80, 0, 0 }; + const f: Frame = .{ .kind = .diff, .cols = 1, .rows = 1, .cursor = null, .nruns = 1, .runs = &runs }; + try testing.expectError(error.BadValue, f.apply(&grid)); + runs[11] = 0; + runs[7] = 8; // len past Cell.text + try testing.expectError(error.BadValue, f.apply(&grid)); + runs[7] = 0; // ...and a grapheme of no bytes at all + try testing.expectError(error.BadValue, f.apply(&grid)); + } +} + +test "detached wire: max_payload bounds every message this protocol can build" { + // The derivation in `max_payload`'s comment, asserted: the worst frame + // this protocol admits fits, so a receiver sized for max_payload can + // always hold one. + try testing.expect(frameBound(max_cols, max_rows) <= max_payload); + // ...and a 4 MiB paste, which is the tty frontend's own cap. + try testing.expect((4 << 20) + header_len + 4 <= max_payload); + // A slice past the width of its own length prefix is refused by the + // ENCODER, rather than written and rejected at the far end. A command line + // is u16-prefixed because nothing legitimately types 64 KiB of one. + const gpa = testing.allocator; + const huge = try gpa.alloc(u8, std.math.maxInt(u16) + 1); + defer gpa.free(huge); + @memset(huge, 'x'); + const room = try gpa.alloc(u8, huge.len + header_len + 2); + defer gpa.free(room); + try testing.expectError(error.Overlong, encodeClient(room, .{ .event = .{ .command = huge } })); + // ...and a buffer the caller sized too small is NoSpace, which is the + // server's signal to drop that one message rather than the client. + var small: [8]u8 = undefined; + try testing.expectError(error.NoSpace, encodeClient(&small, .{ .event = .{ .paste = "0123456789" } })); +} |
