//! 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-one methods; this carries FIVE of //! them — `push_present` as `frame`, `push_set_clipboard`, //! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the sixteen //! it does not are named here with their reasons. The five are spelled out //! because this arithmetic has now gone stale twice in one day, once when the //! machine-local eight moved into the daemon and once when `detach` arrived, //! and a count nobody can check against a list is a comment that rots quietly. //! * The eight machine-local ones — `push_spawn`, `push_pty_write`, //! `push_pty_resize`, `push_write_file`, `push_write_dump`, //! `push_watch_file`, `push_watch_theme`, `push_dump_themes` — are //! performed by the detached core ITSELF, through `host_io.zig`. A unix //! socket means it is on the same machine, so there is no question of //! whose disk or whose process table is meant, and a pane's shell has to //! outlive the frontend that asked for it or a detached session is a //! promise it cannot keep. The `ServerTag` doc below carries the whole of //! that argument; this line exists so the count at the top of the file //! agrees with it. //! * `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. Two 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. /// 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. /// /// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a /// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window /// manager produces. Six numbers are missing from the input run — 0x13..0x17 /// and 0x1e — and the gaps are left rather than tidied away, because /// renumbering is a change every deployed frontend feels. They were `output`, /// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the /// MACHINE-LOCAL host's own reports, which stopped being a frontend's business /// when the daemon took the disk and the process table (host_io.zig, /// file_watch.zig). No frontend ever produced one — tty.zig's attached loop /// swallowed them by name and gui.zig never handed `Input.post` one — and /// leaving them DECODABLE was not merely dead weight: server.zig's `apply` /// routes any decoded non-resize event straight into `core.update`, so an /// attached peer could forge a pane's output, forge an `eof` for a shell that /// was still running (and unlike the daemon's own `paneEof` the wire path never /// called `closePty`, so the master stayed open and the shell was orphaned for /// the life of the session), or replace a pane's text with bytes the next /// `Save` would write to disk. client.zig's header says a machine-local effect /// cannot return to the wire; deleting these is what makes that true in BOTH /// directions instead of only core -> frontend. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, key = 0x10, mouse = 0x11, resize = 0x12, paste = 0x18, command = 0x19, pdf_scroll = 0x1a, pinch = 0x1b, touch_scroll = 0x1c, pointer_leave = 0x1d, }; /// 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. /// /// There are only THREE of those left, and which three is the whole design. /// The daemon performs every effect that needs a disk or a process table /// itself (see `host_io.zig`): a unix socket means it is on the same machine, /// so there is no question of whose disk is meant, and a pane's shell has to /// outlive the frontend that asked for it or a detached session is a promise /// it cannot keep. What is left on the wire is what a process nobody is /// looking at genuinely cannot do — put something on THIS human's clipboard, /// read it back, and open a link in front of the person who clicked it. /// /// `detach` is in the SESSION range and not among those three on purpose: it is /// not an effect the core wants performed, it is the session telling one /// frontend that it is done. `quit` is its sibling — same shape, opposite /// meaning about whether anything survives. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, frame = 0x03, quit = 0x04, /// One frontend is done, and the session is NOT over. The `Detach` word, /// routed back to the frontend whose keystroke ran it (server.zig ORIGIN, /// ELSE PRIMARY, the same rule `read_clipboard` takes and for the same /// reason). Every other frontend, the core and the pane shells are /// untouched, so leaving is success rather than a failure to report. detach = 0x05, set_clipboard = 0x10, read_clipboard = 0x11, open_link = 0x12, }; /// 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, }; /// `Hello.version` alone, read out of the still-undecoded payload. /// /// Separate from `decodeClient` because of the guarantee the fixed offset above /// exists to give: a decoder that validates `cols` and `rows` and then refuses /// trailing bytes can never deliver it. A v2 hello with one more field would be /// closed as a malformed message, and the `Refusal.version` byte a frontend /// needs in order to say something true would never be sent — which is exactly /// the case the field was put at offset zero for. So the version is asked for /// first, on its own, before any of the layout that may have moved. /// /// An error rather than a zero on a payload shorter than two bytes, so a /// truncated hello stays diagnosable too instead of reading as version 0. pub fn helloVersion(payload: []const u8) Error!u16 { var r: Reader = .init(payload); return r.getU16(); } 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, /// One frontend is done, and the session is NOT over — see `ServerTag`. detach, set_clipboard: []const u8, read_clipboard, open_link: []const u8, }; // --------------------------------------------------------------------------- // 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) { // SEVEN `Event`s a frontend cannot produce, so no `ClientTag` // exists for them and this arm is where the compiler says so. // `fs_req` is the acme mount, raised in the same process that // answers it. The other six are the machine-local host's own // reports — a pty's output and its EOF, a language or pipe worker's // answer, a watched file's new bytes, an animation tick — and after // the daemon took the disk and the process table every one of them // is raised by the process that already holds the core. See // `ClientTag` for what putting them back would let a peer forge. .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .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); }, .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 => {}, // See `clientTag`: no tag, so nothing to encode. .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, }, } try finishMessage(&w, at); return w.written(); } pub fn decodeClient(tag: u8, payload: []const u8) 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 }; }, .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 }, }; 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, .detach => {}, .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, .detach, .read_clipboard => 0, // A frame is bounded by its grid, not by this: see `frameBound`. .frame => |f| frameBound(f.cols, f.rows), .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, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0, .key => |k| k.text.len, .paste => |b| b.len, .command => |line| line.len, // See `clientTag`: not on this wire in this direction. .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .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, .detach => .detach, .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) !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); } 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; // 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, 0x1d), @intFromEnum(ClientTag.pointer_leave)); { const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }); 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)); { const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true }; const got = (try roundClient(&buf, .{ .event = .{ .key = key } })).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 } })).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 } } })).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); } } try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } })).event.paste); try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } })).event.command); { const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } })).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 } })).event.pinch); try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } })).event.touch_scroll); try testing.expectEqual( std.meta.Tag(pardes.Event).pointer_leave, (try roundClient(&buf, .{ .event = .pointer_leave })).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)); try testing.expectEqual(ServerMsg.detach, try roundServer(&buf, .detach)); 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); } test "detached wire: only the display's own effects are on the wire" { // Two guards, because the mistake has two directions. The exhaustive // switch fails to COMPILE if a machine-local effect is added back, which // is the direction that matters: it would hand a frontend work that must // outlive it. The count fails if one of the three is dropped, which would // silently leave a clipboard or a link unanswered on every frontend. try testing.expectEqual(@as(usize, 8), std.enums.values(ServerTag).len); for (std.enums.values(ServerTag)) |t| switch (t) { .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link => {}, }; } test "detached wire: a session is never told to do a frontend's remembering" { // The mirror of the test above, and of client.zig's "a frontend is never // asked to fork, write, or watch". That one pins what may reach a FRONTEND; // this one pins what may reach the SESSION, and until the six tags below it // names were deleted the protocol was asymmetric: server -> client carried // only what a display can do, while client -> server still carried the // machine-local host's own reports, which server.zig's `apply` hands // straight to `core.update`. // // Adding a name here is meant to be an ARGUMENT, not a formality, and the // bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a // window resized, a paste, a command line executed, a pointer leaving the // window — plus the two session words that say who is speaking. A pty's // output, a worker's answer, a watched file's new bytes and an animation // tick all fail that bar the same way: nobody did them, a machine reported // them, and the machine that reports them is the one already holding the // core. const allowed = [_][]const u8{ // Who this connection is, and that it is finished. "hello", "bye", // ...and everything a person can do to a window. "key", "mouse", "resize", "paste", "command", "pdf_scroll", "pinch", "touch_scroll", "pointer_leave", }; // One: the tag set is exactly that, named rather than counted, so // re-adding `output` fails with the name in the failure. inline for (std.enums.values(ClientTag)) |t| { for (allowed) |ok| { if (std.mem.eql(u8, @tagName(t), ok)) break; } else { std.debug.print("ClientTag.{s} is not something a human did\n", .{@tagName(t)}); return error.MachineLocalReportOnTheWire; } } try testing.expectEqual(allowed.len, std.enums.values(ClientTag).len); // Two: and no tag BYTE outside them decodes either — the check above is // about this build's enum, this one is about the bytes on the socket. The // six deleted numbers (0x13..0x17, 0x1e) are in the 250 that must answer // `BadTag`, so a frontend built before this change cannot forge a pane's // output into a session built after it. var accepted: usize = 0; for (0..256) |i| { const tag: u8 = @intCast(i); // Empty payloads on purpose: what is asked is whether the TAG is known. // A known one fails later (`Truncated`) or succeeds, never with // `BadTag`. if (decodeClient(tag, &.{})) |_| accepted += 1 else |err| switch (err) { error.BadTag => continue, else => accepted += 1, } const named = for (allowed) |ok| { const want = std.meta.stringToEnum(ClientTag, ok).?; if (@intFromEnum(want) == tag) break true; } else false; if (!named) { std.debug.print("tag 0x{x:0>2} is decodable by a session and is not something a human did\n", .{tag}); return error.MachineLocalReportOnTheWire; } } try testing.expectEqual(allowed.len, accepted); } /// 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; // 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])); } // 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.pointer_leave), "x")); try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x")); } test "detached wire: an unknown tag is refused, never guessed" { // 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, "")); 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 }, )); } 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 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)); // ...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)); } 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" { // 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 }, )); // 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 }, )); // A pane the core cannot index. `pdf_scroll` reads its pane byte before the // f32 that follows, so a one-byte payload reaches the check this is about // rather than tripping `Truncated` first. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.pdf_scroll), &.{pardes.MAX_PANES}, )); // 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 }, )); { 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)); } // 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)); std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little); try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch)); } // 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" } })); }