//! Rendering-agnostic implementation of the [Kitty terminal graphics protocol]. //! //! Parses APC-payload bytes from a VT stream and emits structured commands //! (transmit, place, delete). Does not decode images, does not render pixels, //! does not own a grid — the host does all three. The crate manages command //! parsing, chunk reassembly, image-ID lifecycle, and placement metadata. //! //! [Kitty terminal graphics protocol]: https://sw.kovidgoyal.net/kitty/graphics-protocol/ //! //! Covered: `a=T` transmit+display, `a=t` transmit, `a=p` place, `a=d` //! delete, `a=f` frame append, `a=c` frame compose, `a=q` query, formats //! `f=24` (RGB), `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline), //! chunked payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't- //! move-cursor (`C=1`), Unicode-placeholder flag (`U=1`). //! //! Not covered yet: file/temp/shm media, `a=a` animation control, placement //! IDs, z-order, delete sub-selectors. //! //! State held for a transmission that has not finished is bounded by //! [`MAX_IN_FLIGHT_TRANSMISSIONS`] and [`MAX_IN_FLIGHT_BYTES`]. The bytes //! arrive from whatever program holds the far end of the PTY, so nothing here //! may grow with what a writer chooses to send. //! //! [`Command::Query`] is the one command the host must answer rather than //! merely act on. [`query_response`] builds the reply; the host writes it //! back to the PTY. //! //! Reference read: rio's `rio-backend/src/ansi/kitty_graphics_protocol.rs` //! (MIT) — architecture consulted, no direct code copied. #![deny(unsafe_code)] pub mod oracle; use std::collections::HashMap; use base64::{Engine, engine::general_purpose::STANDARD as B64}; /// Pixel format of a transmit payload. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Format { Rgb, Rgba, Png, } /// Transmission medium. MVP only handles `Direct` (base64 inline). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Medium { Direct, File, TempFile, Shm, } /// Parsed control fields from one kitty-graphics APC command. #[derive(Clone, Debug, Default)] pub struct Control { pub action: char, pub format: Option, pub medium: Option, /// Image ID (`i=`) — server-assigned identity. pub id: Option, /// Image number (`I=`) — client-assigned identity. pub number: Option, /// Placement ID (`p=`). pub placement: Option, /// Width in pixels (`s=`, required for raw formats). pub width_px: Option, /// Height in pixels (`v=`, required for raw formats). pub height_px: Option, /// Display width in cells (`c=`). pub cell_cols: Option, /// Display height in cells (`r=`). pub cell_rows: Option, /// Don't move cursor after placement (`C=1`). pub no_cursor_move: bool, /// Unicode-placeholder placement flag (`U=1`) — image is meant to be /// positioned via placeholder characters in the text stream, not at the /// cursor. pub unicode_placeholder: bool, /// `q=` quiet mode — 0 = verbose, 1 = suppress OK, 2 = suppress errors. pub quiet: u8, /// Set when the chunk carried `m=1` (more chunks follow). pub more_chunks: bool, /// Set when this chunk is `m=0` — either last of a series or a standalone /// command. pub last_chunk: bool, } /// One high-level protocol event ready for the host to act on. Payload bytes /// are already base64-decoded and chunks are already reassembled. #[derive(Clone, Debug)] pub enum Command { /// Transmit and (if control.action == 'T') display an image. Transmit { control: Control, payload: Vec }, /// Place an already-transmitted image at the cursor or via a Unicode /// placeholder. Carries no payload — placement is metadata-only. Place { control: Control }, /// Delete images. MVP only supports "delete all placements" (`a=d,d=A`) — /// the sub-selector variants are stored but not filtered. Delete { control: Control }, /// Append a frame to an animated image (`a=f`). Payload carries the /// frame's pixel data in the same encoding as a Transmit. FrameAppend { control: Control, payload: Vec }, /// Compose an already-transmitted frame from other frames (`a=c`). /// Carries no payload — composition is metadata-only. FrameCompose { control: Control }, /// Capability query (`a=q`). The sender is asking whether a transmission /// shaped like this one would have worked; nothing is stored either way. /// /// This is how a terminal that no program has heard of still gets its /// graphics support noticed. Clients keep a list of terminals they know /// by name and fall back to querying when the name means nothing to /// them, so answering is the difference between being detected and being /// assumed incapable. /// /// The caller must reply. Unlike every other action, a query ignores /// `q=` suppression: silence is not a valid answer to it, and a client /// that asked will wait out its timeout before giving up. Query { control: Control }, } /// What a terminal should answer a [`Command::Query`] with. /// /// The reply is addressed by the `i=` the query carried, so a client can /// match it to the question. A query with no id is answered with `i=0`, /// which is what the protocol's own examples do. #[must_use] pub fn query_response(control: &Control) -> Vec { let id = control.id.unwrap_or(0); // Direct is the only medium shop can satisfy: the others hand over a // path or a shared-memory name to read out of band, and none of that is // implemented. Saying OK to one would promise a picture that never // arrives. let supported = control.format.is_some() && matches!(control.medium, None | Some(Medium::Direct)); if supported { format!("\x1b_Gi={id};OK\x1b\\").into_bytes() } else { // ENOTSUPP is the protocol's spelling for "understood, cannot do it". // Answering with an error still counts as answering: the client stops // waiting and picks another path, which is the whole point. format!("\x1b_Gi={id};ENOTSUPP\x1b\\").into_bytes() } } /// Decoded payload bytes every in-flight transmission may hold between them. /// /// A single budget rather than one per transmission, so the ceiling does not /// multiply by [`MAX_IN_FLIGHT_TRANSMISSIONS`]. /// /// The protocol sets no limit of its own, so this is sized off what a real /// image is: a full-screen 4K RGBA frame is about 33 MB, and kitty's own /// default storage quota for every image it holds is 320 MB. 64 MiB is /// therefore past any single legitimate transmission and far under the budget /// a terminal is expected to have. Reaching it costs an attacker 85 MB of PTY /// traffic for 64 MiB of retention, so there is no amplification either. /// /// Checked against the upper bound of a chunk *before* the accumulator is /// resized, because that arithmetic runs over a length the writer chose. pub const MAX_IN_FLIGHT_BYTES: usize = 64 * 1024 * 1024; /// Transmissions that may be part-way through at once. The oldest is dropped /// to make room for a new one. /// /// The protocol's own answer is one: a client "must finish sending all chunks /// for a single image before sending any other graphics related escape codes". /// Sixteen is deliberately more forgiving than that, because a client that /// interleaves two images is doing something the protocol forbids but that /// this parser has always handled correctly, and there is no reason to start /// corrupting it. What sixteen does refuse is the id-space attack: without a /// cap, 200,000 unfinished chunks under distinct `i=` values leave 200,000 /// entries behind for the life of the terminal, in a map keyed by a number the /// writer chose. /// /// Evicting the oldest is the choice the protocol implies. It says nothing /// about abandoned transmissions, but it does say that when quota runs short /// "existing images without placements will be preferentially deleted", so /// dropping the least recently advanced unfinished transfer is in keeping. A /// client whose transfer is dropped sees its final chunk produce nothing, /// which is the same thing it sees for any other malformed transfer; the /// protocol has no response code for "your transmission was evicted". pub const MAX_IN_FLIGHT_TRANSMISSIONS: usize = 16; /// Parser for kitty-graphics APC payloads. Feed one APC body at a time via /// [`Parser::feed`]. Returns `Some(Command)` once a chunked transmission /// completes; returns `None` while more chunks are expected or for a chunk /// that couldn't be parsed. #[derive(Debug, Default)] pub struct Parser { partial: HashMap, /// Ticks once per chunk accepted, so [`Partial::last_advanced`] orders the /// map for eviction. A `HashMap` has no order of its own and an eviction /// that picked arbitrarily would drop whichever transfer the hasher felt /// like. clock: u64, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] enum PartialKey { ById(u32), ByNumber(u32), /// Fallback when the client didn't send an id or number; single-in-flight. Anon, } #[derive(Debug)] struct Partial { control: Control, payload: Vec, /// Set once the transmission has asked for more than /// [`MAX_TRANSMISSION_BYTES`]. Later chunks are parsed and dropped rather /// than buffered, and the completed command is never emitted. /// /// A flag rather than removing the entry, because removing it would leave /// the closing `m=0` chunk looking like a fresh single-shot transmission /// and hand the host a few bytes of tail as a whole image. overflowed: bool, /// The parser's clock when this transmission last took a chunk. The /// smallest is the one eviction takes. last_advanced: u64, } impl Parser { pub fn new() -> Self { Self::default() } /// Transmissions this parser is holding chunks for, at most /// [`MAX_IN_FLIGHT_TRANSMISSIONS`]. /// /// Exposed so the soak oracle can assert the ceiling instead of waiting /// for libFuzzer's RSS limit to notice. A client that starts a chunked /// transmission and never finishes it holds an entry until it is evicted. #[must_use] pub fn pending_transmissions(&self) -> usize { self.partial.len() } /// Bytes held across all in-flight transmissions: the decoded payloads and /// the map that keys them. /// /// The map's own spine is counted because that is where an attack on the /// id space lands. A client sending one unfinished chunk per `i=` retains /// almost no payload and an entry per id, so a payload-only measure would /// report a few bytes while the process grew by a gigabyte. /// /// Bounded by [`MAX_IN_FLIGHT_BYTES`], but not equal to it: the /// accumulators grow by doubling, so capacity runs ahead of the length the /// budget is checked against, by up to a factor of two. #[must_use] pub fn pending_bytes(&self) -> usize { self.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>() + self .partial .values() .map(|p| p.payload.capacity()) .sum::() } /// Consume one APC body (the bytes between `\e_G` and the ST/BEL /// terminator). Bodies have the form `;` or /// `` alone (place/delete/compose with no payload). pub fn feed(&mut self, body: &[u8]) -> Option { // The protocol always uses `G` as the introducer. Callers may or may // not have stripped it; tolerate both. let body = body.strip_prefix(b"G").unwrap_or(body); let (control_bytes, payload_b64) = split_once_byte(body, b';'); let control = parse_control(control_bytes)?; // Actions that carry image data require the `;` separator, even if // the payload after it is empty (chunk continuation). Reject header- // only forms of these — a strict, conformant parser wouldn't accept // an `a=T` with no payload marker. let needs_payload = matches!(control.action, 'T' | 't' | 'f'); if needs_payload && payload_b64.is_none() { return None; } match control.action { // Payload-carrying actions share the chunk-reassembly path. A // continuation chunk parses with the default action ('T'), so // route the completed command using the *stored* first-chunk // action, not the incoming chunk's action. 'T' | 't' | 'f' => { let b64 = payload_b64.unwrap_or(&[]); let (control, payload) = self.accept_chunked(control, b64)?; Some(match control.action { 'f' => Command::FrameAppend { control, payload }, _ => Command::Transmit { control, payload }, }) } // Payload-less actions. If a `;` was sent anyway, validate // that it's decodable so a malformed one still errors — preserves // the pre-refactor contract without keeping the decoded bytes. // // A query belongs here despite usually carrying a payload: it is // asking about a shape, not sending an image, so the bytes are // checked and dropped rather than reassembled. Probes send a // single pixel, so there is no chunking to honour either. 'p' | 'c' | 'd' | 'q' => { if let Some(b) = payload_b64 { if !b.is_empty() && B64.decode(b).is_err() { return None; } } match control.action { 'p' => Some(Command::Place { control }), 'c' => Some(Command::FrameCompose { control }), 'd' => Some(Command::Delete { control }), 'q' => Some(Command::Query { control }), _ => unreachable!(), } } _ => { tracing::trace!("kitty-graphics: unhandled action {}", control.action); None } } } /// Reassemble chunked payload for actions that carry image data. /// Returns `Some((control, payload))` when a transmission completes. /// /// Decodes base64 straight into the accumulator to avoid per-chunk /// transient allocation. On decode failure the accumulator is truncated /// back to its pre-call length so a bad chunk doesn't contaminate an /// in-flight transmission. fn accept_chunked( &mut self, control: Control, payload_b64: &[u8], ) -> Option<(Control, Vec)> { let key = if let Some(id) = control.id { PartialKey::ById(id) } else if let Some(n) = control.number { PartialKey::ByNumber(n) } else { PartialKey::Anon }; if !control.more_chunks && !self.partial.contains_key(&key) { // Single-shot — no reassembly needed. Decode into a fresh Vec // sized to the payload; the caller owns the result. if over_budget(decoded_upper_bound(payload_b64)) { return None; } let payload = if payload_b64.is_empty() { Vec::new() } else { B64.decode(payload_b64).ok()? }; return Some((control, payload)); } // Opening a new transmission is the moment the map can grow, so it is // the moment eviction has to run. if !self.partial.contains_key(&key) { self.evict_until_room_for_one(); } self.clock += 1; let clock = self.clock; // Held over the borrow below, because the budget is a property of the // whole map and the entry borrows it mutably. let held_elsewhere = self.in_flight_bytes_excluding(key); let entry = self.partial.entry(key).or_insert_with(|| Partial { control: control.clone(), payload: Vec::new(), overflowed: false, last_advanced: clock, }); entry.last_advanced = clock; if !payload_b64.is_empty() { let start = entry.payload.len(); // Upper bound: 3 decoded bytes per 4 base64 chars, rounded up. let extra = decoded_upper_bound(payload_b64); // Before the resize, not after: the arithmetic below runs over a // length the writer chose, and the resize is what commits it. let after = held_elsewhere.saturating_add(start).saturating_add(extra); if entry.overflowed || over_budget(after) { entry.overflowed = true; } else { entry.payload.resize(start + extra, 0); match B64.decode_slice(payload_b64, &mut entry.payload[start..]) { Ok(written) => entry.payload.truncate(start + written), Err(_) => { entry.payload.truncate(start); return None; } } } } // Merge in fields the first chunk didn't carry. merge_control(&mut entry.control, &control); if control.more_chunks { None } else { let Partial { control, payload, overflowed, .. } = self.partial.remove(&key)?; // An over-budget transmission is dropped whole. Handing the host // the leading bytes of an image whose header claims more is a // different image, not a smaller one, and the format decoders // downstream would be reading a truncated stream. if overflowed { return None; } Some((control, payload)) } } /// Decoded bytes held by every in-flight transmission except `key`. /// /// Summed rather than tracked in a counter. The map holds at most /// [`MAX_IN_FLIGHT_TRANSMISSIONS`] entries, so this is sixteen additions /// per chunk, and a counter kept alongside the payloads is one more thing /// that can disagree with them. fn in_flight_bytes_excluding(&self, key: PartialKey) -> usize { self.partial .iter() .filter(|(k, _)| **k != key) .map(|(_, p)| p.payload.len()) .sum() } /// Drops the least recently advanced transmissions until one more fits. fn evict_until_room_for_one(&mut self) { while self.partial.len() >= MAX_IN_FLIGHT_TRANSMISSIONS { let Some(oldest) = self .partial .iter() .min_by_key(|(_, p)| p.last_advanced) .map(|(k, _)| *k) else { break; }; self.partial.remove(&oldest); } // `remove` leaves the table's capacity behind, and the table is what // an attack on the id space grows. Without this a burst of 200,000 // distinct ids would leave a 200,000-slot table allocated for the life // of the terminal even though it holds sixteen entries. self.partial.shrink_to_fit(); } } /// Decoded bytes `payload_b64` could produce: 3 per 4 base64 characters, /// rounded up. An upper bound rather than the exact figure, because it has to /// be known before the decode and it is what the accumulator is sized to. fn decoded_upper_bound(payload_b64: &[u8]) -> usize { payload_b64.len().div_ceil(4) * 3 } /// Whether `bytes` of decoded payload is more than the in-flight budget allows. /// /// A named decision rather than two inline `>` comparisons, because the /// boundary is only reachable through 89 MB of base64 otherwise: the mutant /// that widens it to `>=` differs from the original at exactly /// [`MAX_IN_FLIGHT_BYTES`] and nowhere else. Here a test can state the boundary /// directly, and both callers get it from one place. fn over_budget(bytes: usize) -> bool { bytes > MAX_IN_FLIGHT_BYTES } /// Split at the first `sep`. Returns `(before, None)` when no `sep` byte is /// present — the caller uses that to distinguish "empty payload after `;`" /// from "no `;` at all". fn split_once_byte(bytes: &[u8], sep: u8) -> (&[u8], Option<&[u8]>) { match bytes.iter().position(|&b| b == sep) { Some(i) => (&bytes[..i], Some(&bytes[i + 1..])), None => (bytes, None), } } fn parse_control(bytes: &[u8]) -> Option { let mut c = Control { action: 'T', ..Control::default() }; let text = std::str::from_utf8(bytes).ok()?; for kv in text.split(',') { let (k, v) = kv.split_once('=')?; match k { "a" => c.action = v.chars().next()?, "f" => { c.format = match v { "24" => Some(Format::Rgb), "32" => Some(Format::Rgba), "100" => Some(Format::Png), _ => None, } } "t" => { c.medium = match v { "d" => Some(Medium::Direct), "f" => Some(Medium::File), "t" => Some(Medium::TempFile), "s" => Some(Medium::Shm), _ => None, } } "i" => c.id = v.parse().ok(), "I" => c.number = v.parse().ok(), "p" => c.placement = v.parse().ok(), "s" => c.width_px = v.parse().ok(), "v" => c.height_px = v.parse().ok(), "c" => c.cell_cols = v.parse().ok(), "r" => c.cell_rows = v.parse().ok(), "C" => c.no_cursor_move = v == "1", "U" => c.unicode_placeholder = v == "1", "q" => c.quiet = v.parse().unwrap_or(0), "m" => match v { "0" => c.last_chunk = true, "1" => c.more_chunks = true, _ => {} }, _ => {} } } if !c.more_chunks { c.last_chunk = true; } Some(c) } fn merge_control(into: &mut Control, from: &Control) { if into.format.is_none() { into.format = from.format; } if into.width_px.is_none() { into.width_px = from.width_px; } if into.height_px.is_none() { into.height_px = from.height_px; } if into.cell_cols.is_none() { into.cell_cols = from.cell_cols; } if into.cell_rows.is_none() { into.cell_rows = from.cell_rows; } if from.no_cursor_move { into.no_cursor_move = true; } } #[cfg(test)] mod tests { use super::*; use base64::Engine; fn b64(bytes: &[u8]) -> String { B64.encode(bytes) } fn transmit(cmd: Command) -> (Control, Vec) { match cmd { Command::Transmit { control, payload } => (control, payload), other => panic!("expected Transmit, got {other:?}"), } } // ---- the bounds as numbers, and the accounting that reads them ------ // // A cap the parser stays self-consistent under is invisible to every // behavioural test: the parser enforces whatever number is there. These // state the numbers and the arithmetic a second time, which is the only // way a change to either is a disagreement rather than a new baseline. #[test] fn the_in_flight_budget_is_sixty_four_mebibytes() { // Written as a plain decimal so that a mutant rewriting the product in // the source has nothing here to agree with. assert_eq!(MAX_IN_FLIGHT_BYTES, 67_108_864); } #[test] fn the_budget_boundary_admits_exactly_the_budget() { // `>` vs `>=` disagree at this one value and nowhere else, and the // input that would reach it through feed() is 89 MB of base64. assert!(!over_budget(MAX_IN_FLIGHT_BYTES)); assert!(over_budget(MAX_IN_FLIGHT_BYTES + 1)); assert!(!over_budget(MAX_IN_FLIGHT_BYTES - 1)); } /// A parser holding one unfinished chunked transmission of `payload`. fn parser_holding(payload: &[u8]) -> Parser { let mut p = Parser::new(); let body = format!("Ga=T,f=32,s=64,v=64,i=7,m=1;{}", b64(payload)); assert!( p.feed(body.as_bytes()).is_none(), "an opening chunk emits nothing" ); p } #[test] fn pending_bytes_counts_the_map_spine_and_every_payload() { let p = parser_holding(&[0xAB; 3000]); // The arithmetic stated twice: a mutant that turns the product into a // sum, or the whole body into a constant, disagrees with this. let expected = p.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>() + p.partial .values() .map(|x| x.payload.capacity()) .sum::(); assert_eq!(p.pending_bytes(), expected); assert!( p.pending_bytes() >= 3000, "a transmission holding 3000 bytes cannot report fewer" ); } #[test] fn in_flight_bytes_excluding_leaves_out_the_named_transmission_only() { let mut p = Parser::new(); for (id, len) in [(7u32, 3000usize), (9, 6000)] { let body = format!("Ga=T,f=32,s=64,v=64,i={id},m=1;{}", b64(&vec![0xCD; len])); assert!(p.feed(body.as_bytes()).is_none()); } // Two transmissions of different sizes, so excluding the wrong one, or // returning a constant, gives a different answer from all of these. let seven = p.in_flight_bytes_excluding(PartialKey::ById(7)); let nine = p.in_flight_bytes_excluding(PartialKey::ById(9)); assert_eq!(seven, 6000, "excluding 7 must leave 9's bytes"); assert_eq!(nine, 3000, "excluding 9 must leave 7's bytes"); assert_eq!( p.in_flight_bytes_excluding(PartialKey::Anon), 9000, "excluding a key that is not there leaves both" ); } #[test] fn the_map_never_holds_more_than_the_in_flight_cap() { // Eviction is what keeps an id-space attack bounded, and nothing else // in the suite makes the map grow past the cap. let mut p = Parser::new(); let over = MAX_IN_FLIGHT_TRANSMISSIONS + 24; for id in 0..over { let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x11; 12])); assert!(p.feed(body.as_bytes()).is_none()); assert!( p.pending_transmissions() <= MAX_IN_FLIGHT_TRANSMISSIONS, "{} transmissions in flight after opening {}", p.pending_transmissions(), id + 1 ); } assert_eq!( p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS, "{over} openings must leave exactly the cap behind" ); } // ---- control fields nothing was reading ----------------------------- #[test] fn every_medium_spelling_parses_to_its_own_variant() { // The media the parser does not act on are still parsed, and a deleted // arm makes one of them read as "no medium given", which the query // answer treats as Direct and says OK to. for (spelling, want) in [ ("d", Medium::Direct), ("f", Medium::File), ("t", Medium::TempFile), ("s", Medium::Shm), ] { let c = parse_control(format!("a=q,t={spelling}").as_bytes()) .unwrap_or_else(|| panic!("t={spelling} did not parse")); assert_eq!(c.medium, Some(want), "t={spelling}"); } assert_eq!( parse_control(b"a=q,t=z").unwrap().medium, None, "an unknown medium is None, not a default" ); } #[test] fn a_body_with_no_m_field_is_its_own_last_chunk() { // The fallback at the end of parse_control. Without it a single-shot // body reports itself unfinished. let c = parse_control(b"a=T,f=32,s=1,v=1").unwrap(); assert!(c.last_chunk, "a body with no m= is complete by itself"); assert!(!c.more_chunks); } #[test] fn m_zero_sets_last_chunk_even_when_m_one_came_first() { // The only input that separates the `m=0` arm from the fallback: the // fallback cannot fire, because more_chunks is set. let c = parse_control(b"a=T,m=1,m=0").unwrap(); assert!(c.last_chunk, "m=0 says last chunk in its own right"); assert!(c.more_chunks); } #[test] fn the_clock_ticks_once_per_accepted_chunk() { // The clock is what orders the map for eviction. Frozen, every entry // carries the same last_advanced and eviction picks by hash order // instead of by age -- which no assertion about *how many* entries // survive can see. let mut p = Parser::new(); for id in 0..5u32 { let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x22; 12])); assert!(p.feed(body.as_bytes()).is_none()); } assert_eq!(p.clock, 5, "one tick per chunk accepted, and no more"); } #[test] fn eviction_drops_the_least_recently_advanced_transmission() { // Not the oldest-opened: a transmission that is still being fed is // live, and dropping it in favour of one that has sat untouched is the // behaviour the clock exists to prevent. let mut p = Parser::new(); for id in 0..MAX_IN_FLIGHT_TRANSMISSIONS as u32 { let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x33; 12])); assert!(p.feed(body.as_bytes()).is_none()); } // Advance the one opened first, so it is no longer the stalest. assert!( p.feed(format!("Gi=0,m=1;{}", b64(&[0x44; 12])).as_bytes()) .is_none() ); // Opening one more has to evict, and 1 is now the stalest. assert!( p.feed(format!("Ga=T,f=32,s=8,v=8,i=99,m=1;{}", b64(&[0x55; 12])).as_bytes()) .is_none() ); assert!( p.partial.contains_key(&PartialKey::ById(0)), "the transmission that was still being fed must survive" ); assert!( !p.partial.contains_key(&PartialKey::ById(1)), "the least recently advanced one is what goes" ); assert_eq!(p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS); } #[test] fn a_later_chunk_can_carry_fields_the_opener_left_out() { // merge_control fills the STORED control from the current chunk, so // this is the direction that observes it: an opener that named no // format, and a closer that does. let payload = [0x6Eu8; 96]; let encoded = b64(&payload); let (head, tail) = encoded.split_at(16); let mut p = Parser::new(); assert!(p.feed(format!("Ga=T,i=12,m=1;{head}").as_bytes()).is_none()); let cmd = p .feed(format!("Gi=12,f=32,s=4,v=8,C=1,m=0;{tail}").as_bytes()) .expect("the closing chunk completes the transmission"); let (control, got) = transmit(cmd); assert_eq!(got, payload); assert_eq!( control.format, Some(Format::Rgba), "f= arrived on the closing chunk and must be kept" ); assert_eq!(control.width_px, Some(4)); assert_eq!(control.height_px, Some(8)); assert!(control.no_cursor_move); } #[test] fn a_continuation_chunk_inherits_the_opening_control() { // merge_control is what carries format and geometry from the opening // chunk to the command; a later chunk repeats only `i=` and `m=`. let payload = [0x7Fu8; 96]; let encoded = b64(&payload); let (head, tail) = encoded.split_at(16); let mut p = Parser::new(); assert!( p.feed(format!("Ga=T,f=32,s=4,v=8,C=1,i=11,m=1;{head}").as_bytes()) .is_none() ); let cmd = p .feed(format!("Gi=11,m=0;{tail}").as_bytes()) .expect("the closing chunk completes the transmission"); let (control, got) = transmit(cmd); assert_eq!(got, payload); assert_eq!( control.format, Some(Format::Rgba), "f= came from the opener" ); assert_eq!(control.width_px, Some(4), "s= came from the opener"); assert_eq!(control.height_px, Some(8), "v= came from the opener"); assert!(control.no_cursor_move, "C= came from the opener"); } // ---- control-field parsing --------------------------------------- #[test] fn header_only_transmit_is_rejected() { // No `;` separator means no payload marker at all. A conformant // parser rejects `a=T` in this shape; earlier versions over-accepted // it as a valid header-only command. let mut p = Parser::new(); assert!(p.feed(b"Gf=32,s=1,v=1").is_none()); assert!(p.feed(b"Ga=T,f=32,s=1,v=1").is_none()); assert!(p.feed(b"Ga=t,f=32,s=1,v=1").is_none()); assert!(p.feed(b"Ga=f,f=32,s=1,v=1").is_none()); } #[test] fn empty_payload_after_semicolon_is_accepted() { // The `;` separator is present but payload is empty — this is a // valid chunk-continuation shape. let mut p = Parser::new(); let (control, payload) = transmit( p.feed(b"Gf=32,s=1,v=1;") .expect("empty-payload transmit with `;` must dispatch"), ); assert_eq!(control.action, 'T'); assert_eq!(control.format, Some(Format::Rgba)); assert!(payload.is_empty()); } #[test] fn parses_full_control_fields() { let mut p = Parser::new(); let body = format!( "Ga=T,f=100,t=d,i=42,I=7,p=3,s=100,v=50,c=8,r=4,C=1,q=1;{}", b64(b"data") ); let (c, payload) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(c.action, 'T'); assert_eq!(c.format, Some(Format::Png)); assert_eq!(c.medium, Some(Medium::Direct)); assert_eq!(c.id, Some(42)); assert_eq!(c.number, Some(7)); assert_eq!(c.placement, Some(3)); assert_eq!(c.width_px, Some(100)); assert_eq!(c.height_px, Some(50)); assert_eq!(c.cell_cols, Some(8)); assert_eq!(c.cell_rows, Some(4)); assert!(c.no_cursor_move); assert_eq!(c.quiet, 1); assert_eq!(payload, b"data"); } #[test] fn tolerates_missing_g_prefix() { // `Parser::feed` strips a leading `G` if present. Callers that // pre-stripped it should still work. let mut p = Parser::new(); let body = format!("a=T,f=32,s=1,v=1;{}", b64(b"xyz")); let (control, payload) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(control.action, 'T'); assert_eq!(payload, b"xyz"); } fn query_of(body: &[u8]) -> Control { let mut p = Parser::new(); match p.feed(body) { Some(Command::Query { control }) => control, other => panic!("expected a query, got {other:?}"), } } #[test] fn a_probe_query_parses_as_a_query() { // Verbatim shape of what a client probe sends: one pixel, direct, // 24-bit, asking rather than transmitting. let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA"); assert_eq!(control.action, 'q'); assert_eq!(control.id, Some(31)); } #[test] fn a_query_is_answered_ok_and_addressed_to_its_id() { let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA"); assert_eq!(query_response(&control), b"\x1b_Gi=31;OK\x1b\\".to_vec()); } #[test] fn a_query_with_no_id_is_answered_against_zero() { let control = query_of(b"Ga=q,f=32,s=1,v=1;AAAA"); assert_eq!(query_response(&control), b"\x1b_Gi=0;OK\x1b\\".to_vec()); } #[test] fn every_format_shop_decodes_answers_ok() { for body in [ b"Ga=q,i=1,f=24,s=1,v=1;AAAA".as_slice(), b"Ga=q,i=1,f=32,s=1,v=1;AAAA".as_slice(), b"Ga=q,i=1,f=100;AAAA".as_slice(), ] { let reply = query_response(&query_of(body)); assert_eq!(reply, b"\x1b_Gi=1;OK\x1b\\".to_vec(), "for {body:?}"); } } #[test] fn a_medium_shop_cannot_read_is_declined_rather_than_ignored() { // Saying OK to a file transfer promises a picture that never // arrives; saying nothing makes the client wait out its timeout. // Both are worse than an honest refusal. let control = query_of(b"Ga=q,i=7,f=100,t=f;L3RtcC94"); assert_eq!( query_response(&control), b"\x1b_Gi=7;ENOTSUPP\x1b\\".to_vec() ); } #[test] fn an_absent_medium_means_direct() { let control = query_of(b"Ga=q,i=2,f=24,s=1,v=1;AAAA"); assert_eq!(query_response(&control), b"\x1b_Gi=2;OK\x1b\\".to_vec()); } #[test] fn a_query_with_no_format_is_declined() { let control = query_of(b"Ga=q,i=3;AAAA"); assert_eq!( query_response(&control), b"\x1b_Gi=3;ENOTSUPP\x1b\\".to_vec() ); } #[test] fn a_query_with_an_undecodable_payload_is_rejected() { let mut p = Parser::new(); assert!(p.feed(b"Ga=q,i=1,f=24;!!!!").is_none()); } #[test] fn a_query_stores_nothing() { // A query must not leave a half-assembled transmission behind for // the next chunk to attach itself to. let mut p = Parser::new(); assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some()); assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some()); } #[test] fn unknown_action_returns_none() { let mut p = Parser::new(); assert!(p.feed(b"Ga=z,f=32;YWJj").is_none()); } #[test] fn malformed_control_returns_none() { // No `=` in a field. let mut p = Parser::new(); assert!(p.feed(b"Ga=T,broken;YWJj").is_none()); } #[test] fn malformed_base64_returns_none() { let mut p = Parser::new(); assert!(p.feed(b"Ga=T,f=32;this-is-not-base64!!!").is_none()); } // ---- formats ----------------------------------------------------- #[test] fn format_24_is_rgb() { let mut p = Parser::new(); let body = format!("Ga=T,f=24,s=1,v=1;{}", b64(&[1, 2, 3])); let (c, _) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(c.format, Some(Format::Rgb)); } #[test] fn format_32_is_rgba() { let mut p = Parser::new(); let body = format!("Ga=T,f=32,s=1,v=1;{}", b64(&[1, 2, 3, 4])); let (c, _) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(c.format, Some(Format::Rgba)); } #[test] fn format_100_is_png() { let mut p = Parser::new(); let body = format!("Ga=T,f=100;{}", b64(b"\x89PNG")); let (c, _) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(c.format, Some(Format::Png)); } // ---- chunked reassembly ------------------------------------------ #[test] fn single_chunk_no_m_dispatches_immediately() { let mut p = Parser::new(); let body = format!("Ga=T,f=32,s=1,v=1;{}", b64(b"solo")); let (_, payload) = transmit(p.feed(body.as_bytes()).unwrap()); assert_eq!(payload, b"solo"); } #[test] fn two_chunks_reassemble_by_id() { let mut p = Parser::new(); let a = format!("Ga=T,f=32,s=2,v=1,i=7,m=1;{}", b64(b"HEAD")); let b = format!("Gi=7,m=0;{}", b64(b"TAIL")); assert!( p.feed(a.as_bytes()).is_none(), "first chunk should not dispatch" ); let (c, payload) = transmit(p.feed(b.as_bytes()).unwrap()); assert_eq!(c.id, Some(7)); // format from the first chunk must persist through merge. assert_eq!(c.format, Some(Format::Rgba)); assert_eq!(c.width_px, Some(2)); assert_eq!(payload, b"HEADTAIL"); } #[test] fn three_chunks_reassemble() { let mut p = Parser::new(); let a = format!("Ga=T,f=32,i=1,m=1;{}", b64(b"AAA")); let b = format!("Gi=1,m=1;{}", b64(b"BBB")); let c = format!("Gi=1,m=0;{}", b64(b"CCC")); assert!(p.feed(a.as_bytes()).is_none()); assert!(p.feed(b.as_bytes()).is_none()); let (_, payload) = transmit(p.feed(c.as_bytes()).unwrap()); assert_eq!(payload, b"AAABBBCCC"); } #[test] fn concurrent_ids_do_not_cross_contaminate() { let mut p = Parser::new(); // Interleave two independent chunked transmissions. let a1 = format!("Ga=T,f=32,i=1,m=1;{}", b64(b"HELLO")); let b1 = format!("Ga=T,f=32,i=2,m=1;{}", b64(b"WORLD")); let a2 = format!("Gi=1,m=0;{}", b64(b"!")); let b2 = format!("Gi=2,m=0;{}", b64(b"?")); assert!(p.feed(a1.as_bytes()).is_none()); assert!(p.feed(b1.as_bytes()).is_none()); let (ca, pa) = transmit(p.feed(a2.as_bytes()).unwrap()); let (cb, pb) = transmit(p.feed(b2.as_bytes()).unwrap()); assert_eq!(ca.id, Some(1)); assert_eq!(cb.id, Some(2)); assert_eq!(pa, b"HELLO!"); assert_eq!(pb, b"WORLD?"); } #[test] fn chunked_by_number_field() { // Use I= (client-assigned number) instead of i=. let mut p = Parser::new(); let a = format!("Ga=T,f=32,I=99,m=1;{}", b64(b"XX")); let b = format!("GI=99,m=0;{}", b64(b"YY")); assert!(p.feed(a.as_bytes()).is_none()); let (_, payload) = transmit(p.feed(b.as_bytes()).unwrap()); assert_eq!(payload, b"XXYY"); } // ---- delete ------------------------------------------------------ #[test] fn delete_returns_delete_command() { let mut p = Parser::new(); let cmd = p.feed(b"Ga=d,d=A").expect("must dispatch"); assert!(matches!(cmd, Command::Delete { .. })); } // ---- place / frame-append / frame-compose ------------------------ #[test] fn place_at_cursor_returns_place_command() { let mut p = Parser::new(); let cmd = p .feed(b"Ga=p,i=1,c=8,r=4") .expect("place must dispatch header-only"); let Command::Place { control } = cmd else { panic!("expected Place, got {cmd:?}"); }; assert_eq!(control.action, 'p'); assert_eq!(control.id, Some(1)); assert_eq!(control.cell_cols, Some(8)); assert_eq!(control.cell_rows, Some(4)); assert!(!control.unicode_placeholder); } #[test] fn place_unicode_placeholder_flag_is_parsed() { let mut p = Parser::new(); let cmd = p .feed(b"Ga=p,U=1,i=2,q=2") .expect("virtual placement must dispatch"); let Command::Place { control } = cmd else { panic!("expected Place, got {cmd:?}"); }; assert!(control.unicode_placeholder); assert_eq!(control.quiet, 2); assert_eq!(control.id, Some(2)); } #[test] fn frame_append_dispatches_with_payload() { let mut p = Parser::new(); let body = format!("Ga=f,f=32,s=1,v=1,i=5;{}", b64(b"FRAME")); let cmd = p.feed(body.as_bytes()).expect("frame append must dispatch"); let Command::FrameAppend { control, payload } = cmd else { panic!("expected FrameAppend, got {cmd:?}"); }; assert_eq!(control.action, 'f'); assert_eq!(control.id, Some(5)); assert_eq!(payload, b"FRAME"); } #[test] fn frame_append_reassembles_chunks() { let mut p = Parser::new(); let a = format!("Ga=f,f=32,i=9,m=1;{}", b64(b"HEAD")); let b = format!("Gi=9,m=0;{}", b64(b"TAIL")); assert!(p.feed(a.as_bytes()).is_none()); let cmd = p.feed(b.as_bytes()).expect("must dispatch on last chunk"); let Command::FrameAppend { control, payload } = cmd else { panic!("expected FrameAppend, got {cmd:?}"); }; assert_eq!(control.id, Some(9)); assert_eq!(payload, b"HEADTAIL"); } #[test] fn frame_compose_dispatches_header_only() { let mut p = Parser::new(); let cmd = p .feed(b"Ga=c,i=3,r=1,c=2") .expect("frame compose must dispatch header-only"); let Command::FrameCompose { control } = cmd else { panic!("expected FrameCompose, got {cmd:?}"); }; assert_eq!(control.action, 'c'); assert_eq!(control.id, Some(3)); } // ---- Bounded in-flight state (the 2026-08-29 DoS finding) ---------- /// A client that opens transmissions and never finishes them holds at most /// [`MAX_IN_FLIGHT_TRANSMISSIONS`], however many ids it invents. #[test] fn distinct_ids_do_not_accumulate_without_bound() { let mut p = Parser::new(); for i in 0..50_000u32 { let body = format!("Ga=T,f=32,i={i},m=1;{}", b64(b"ABC")); assert!(p.feed(body.as_bytes()).is_none()); } assert_eq!(p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS); assert!( p.pending_bytes() < 8 * 1024, "parser kept {} bytes for 50k ids", p.pending_bytes() ); } /// Eviction takes the least recently advanced transfer, so the one a /// client is actively feeding survives a burst of noise beside it. #[test] fn eviction_keeps_the_transmission_being_advanced() { let mut p = Parser::new(); let head = format!("Ga=T,f=32,i=7,m=1;{}", b64(b"HEAD")); assert!(p.feed(head.as_bytes()).is_none()); for round in 0..4 { for i in 100..100 + MAX_IN_FLIGHT_TRANSMISSIONS as u32 - 1 { let noise = format!("Ga=T,f=32,i={},m=1;{}", i + round * 1000, b64(b"NN")); p.feed(noise.as_bytes()); } // Advancing id 7 refreshes it, which is what keeps it alive. let more = format!("Gi=7,m=1;{}", b64(b"-")); p.feed(more.as_bytes()); } let tail = format!("Gi=7,m=0;{}", b64(b"TAIL")); let (control, payload) = transmit(p.feed(tail.as_bytes()).expect("id 7 survived")); assert_eq!(control.id, Some(7)); assert_eq!(payload, b"HEAD----TAIL"); } /// A transmission past the byte budget is dropped whole rather than handed /// over truncated, and its closing chunk does not read as a fresh /// single-shot image. #[test] fn a_transmission_past_the_budget_is_dropped_whole() { let mut p = Parser::new(); // One chunk that alone asks for more than the budget. let huge = b64(&vec![0u8; 4096]); let first = format!("Ga=T,f=32,i=1,m=1;{huge}"); assert!(p.feed(first.as_bytes()).is_none()); let oversized = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 * 4 + 8); let second = format!("Gi=1,m=1;{oversized}"); assert!(p.feed(second.as_bytes()).is_none()); let tail = format!("Gi=1,m=0;{}", b64(b"TAIL")); assert!( p.feed(tail.as_bytes()).is_none(), "an over-budget transmission must not complete" ); assert_eq!(p.pending_transmissions(), 0, "and must not linger"); } /// A single-shot body over the budget is refused rather than decoded. #[test] fn a_single_shot_past_the_budget_is_refused() { let mut p = Parser::new(); let oversized = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 * 4 + 8); let body = format!("Ga=T,f=32,i=1;{oversized}"); assert!(p.feed(body.as_bytes()).is_none()); assert_eq!(p.pending_transmissions(), 0); } /// The budget is shared, so many transmissions cannot each claim it. #[test] fn the_byte_budget_is_shared_across_transmissions() { let mut p = Parser::new(); // Roughly a third of the budget per transmission, three times over. let third = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 / 3 * 4); for i in 0..3u32 { let body = format!("Ga=T,f=32,i={i},m=1;{third}"); assert!(p.feed(body.as_bytes()).is_none()); } assert!( p.pending_bytes() <= MAX_IN_FLIGHT_BYTES * 2, "in-flight bytes reached {}", p.pending_bytes() ); } }