Skip to main content

max / shop

47.8 KB · 1238 lines History Blame Raw
1 //! Rendering-agnostic implementation of the [Kitty terminal graphics protocol].
2 //!
3 //! Parses APC-payload bytes from a VT stream and emits structured commands
4 //! (transmit, place, delete). Does not decode images, does not render pixels,
5 //! does not own a grid — the host does all three. The crate manages command
6 //! parsing, chunk reassembly, image-ID lifecycle, and placement metadata.
7 //!
8 //! [Kitty terminal graphics protocol]: https://sw.kovidgoyal.net/kitty/graphics-protocol/
9 //!
10 //! Covered: `a=T` transmit+display, `a=t` transmit, `a=p` place, `a=d`
11 //! delete, `a=f` frame append, `a=c` frame compose, `a=q` query, formats
12 //! `f=24` (RGB), `f=32` (RGBA), `f=100` (PNG), medium `t=d` (base64 inline),
13 //! chunked payloads (`m=1`/`m=0`), placement in cells (`c=`, `r=`), don't-
14 //! move-cursor (`C=1`), Unicode-placeholder flag (`U=1`).
15 //!
16 //! Not covered yet: file/temp/shm media, `a=a` animation control, placement
17 //! IDs, z-order, delete sub-selectors.
18 //!
19 //! State held for a transmission that has not finished is bounded by
20 //! [`MAX_IN_FLIGHT_TRANSMISSIONS`] and [`MAX_IN_FLIGHT_BYTES`]. The bytes
21 //! arrive from whatever program holds the far end of the PTY, so nothing here
22 //! may grow with what a writer chooses to send.
23 //!
24 //! [`Command::Query`] is the one command the host must answer rather than
25 //! merely act on. [`query_response`] builds the reply; the host writes it
26 //! back to the PTY.
27 //!
28 //! Reference read: rio's `rio-backend/src/ansi/kitty_graphics_protocol.rs`
29 //! (MIT) — architecture consulted, no direct code copied.
30
31 #![deny(unsafe_code)]
32
33 pub mod oracle;
34
35 use std::collections::HashMap;
36
37 use base64::{Engine, engine::general_purpose::STANDARD as B64};
38
39 /// Pixel format of a transmit payload.
40 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
41 pub enum Format {
42 Rgb,
43 Rgba,
44 Png,
45 }
46
47 /// Transmission medium. MVP only handles `Direct` (base64 inline).
48 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
49 pub enum Medium {
50 Direct,
51 File,
52 TempFile,
53 Shm,
54 }
55
56 /// Parsed control fields from one kitty-graphics APC command.
57 #[derive(Clone, Debug, Default)]
58 pub struct Control {
59 pub action: char,
60 pub format: Option<Format>,
61 pub medium: Option<Medium>,
62 /// Image ID (`i=`) — server-assigned identity.
63 pub id: Option<u32>,
64 /// Image number (`I=`) — client-assigned identity.
65 pub number: Option<u32>,
66 /// Placement ID (`p=`).
67 pub placement: Option<u32>,
68 /// Width in pixels (`s=`, required for raw formats).
69 pub width_px: Option<u32>,
70 /// Height in pixels (`v=`, required for raw formats).
71 pub height_px: Option<u32>,
72 /// Display width in cells (`c=`).
73 pub cell_cols: Option<u32>,
74 /// Display height in cells (`r=`).
75 pub cell_rows: Option<u32>,
76 /// Don't move cursor after placement (`C=1`).
77 pub no_cursor_move: bool,
78 /// Unicode-placeholder placement flag (`U=1`) — image is meant to be
79 /// positioned via placeholder characters in the text stream, not at the
80 /// cursor.
81 pub unicode_placeholder: bool,
82 /// `q=` quiet mode — 0 = verbose, 1 = suppress OK, 2 = suppress errors.
83 pub quiet: u8,
84 /// Set when the chunk carried `m=1` (more chunks follow).
85 pub more_chunks: bool,
86 /// Set when this chunk is `m=0` — either last of a series or a standalone
87 /// command.
88 pub last_chunk: bool,
89 }
90
91 /// One high-level protocol event ready for the host to act on. Payload bytes
92 /// are already base64-decoded and chunks are already reassembled.
93 #[derive(Clone, Debug)]
94 pub enum Command {
95 /// Transmit and (if control.action == 'T') display an image.
96 Transmit { control: Control, payload: Vec<u8> },
97 /// Place an already-transmitted image at the cursor or via a Unicode
98 /// placeholder. Carries no payload — placement is metadata-only.
99 Place { control: Control },
100 /// Delete images. MVP only supports "delete all placements" (`a=d,d=A`) —
101 /// the sub-selector variants are stored but not filtered.
102 Delete { control: Control },
103 /// Append a frame to an animated image (`a=f`). Payload carries the
104 /// frame's pixel data in the same encoding as a Transmit.
105 FrameAppend { control: Control, payload: Vec<u8> },
106 /// Compose an already-transmitted frame from other frames (`a=c`).
107 /// Carries no payload — composition is metadata-only.
108 FrameCompose { control: Control },
109 /// Capability query (`a=q`). The sender is asking whether a transmission
110 /// shaped like this one would have worked; nothing is stored either way.
111 ///
112 /// This is how a terminal that no program has heard of still gets its
113 /// graphics support noticed. Clients keep a list of terminals they know
114 /// by name and fall back to querying when the name means nothing to
115 /// them, so answering is the difference between being detected and being
116 /// assumed incapable.
117 ///
118 /// The caller must reply. Unlike every other action, a query ignores
119 /// `q=` suppression: silence is not a valid answer to it, and a client
120 /// that asked will wait out its timeout before giving up.
121 Query { control: Control },
122 }
123
124 /// What a terminal should answer a [`Command::Query`] with.
125 ///
126 /// The reply is addressed by the `i=` the query carried, so a client can
127 /// match it to the question. A query with no id is answered with `i=0`,
128 /// which is what the protocol's own examples do.
129 #[must_use]
130 pub fn query_response(control: &Control) -> Vec<u8> {
131 let id = control.id.unwrap_or(0);
132 // Direct is the only medium shop can satisfy: the others hand over a
133 // path or a shared-memory name to read out of band, and none of that is
134 // implemented. Saying OK to one would promise a picture that never
135 // arrives.
136 let supported =
137 control.format.is_some() && matches!(control.medium, None | Some(Medium::Direct));
138 if supported {
139 format!("\x1b_Gi={id};OK\x1b\\").into_bytes()
140 } else {
141 // ENOTSUPP is the protocol's spelling for "understood, cannot do it".
142 // Answering with an error still counts as answering: the client stops
143 // waiting and picks another path, which is the whole point.
144 format!("\x1b_Gi={id};ENOTSUPP\x1b\\").into_bytes()
145 }
146 }
147
148 /// Decoded payload bytes every in-flight transmission may hold between them.
149 ///
150 /// A single budget rather than one per transmission, so the ceiling does not
151 /// multiply by [`MAX_IN_FLIGHT_TRANSMISSIONS`].
152 ///
153 /// The protocol sets no limit of its own, so this is sized off what a real
154 /// image is: a full-screen 4K RGBA frame is about 33 MB, and kitty's own
155 /// default storage quota for every image it holds is 320 MB. 64 MiB is
156 /// therefore past any single legitimate transmission and far under the budget
157 /// a terminal is expected to have. Reaching it costs an attacker 85 MB of PTY
158 /// traffic for 64 MiB of retention, so there is no amplification either.
159 ///
160 /// Checked against the upper bound of a chunk *before* the accumulator is
161 /// resized, because that arithmetic runs over a length the writer chose.
162 pub const MAX_IN_FLIGHT_BYTES: usize = 64 * 1024 * 1024;
163
164 /// Transmissions that may be part-way through at once. The oldest is dropped
165 /// to make room for a new one.
166 ///
167 /// The protocol's own answer is one: a client "must finish sending all chunks
168 /// for a single image before sending any other graphics related escape codes".
169 /// Sixteen is deliberately more forgiving than that, because a client that
170 /// interleaves two images is doing something the protocol forbids but that
171 /// this parser has always handled correctly, and there is no reason to start
172 /// corrupting it. What sixteen does refuse is the id-space attack: without a
173 /// cap, 200,000 unfinished chunks under distinct `i=` values leave 200,000
174 /// entries behind for the life of the terminal, in a map keyed by a number the
175 /// writer chose.
176 ///
177 /// Evicting the oldest is the choice the protocol implies. It says nothing
178 /// about abandoned transmissions, but it does say that when quota runs short
179 /// "existing images without placements will be preferentially deleted", so
180 /// dropping the least recently advanced unfinished transfer is in keeping. A
181 /// client whose transfer is dropped sees its final chunk produce nothing,
182 /// which is the same thing it sees for any other malformed transfer; the
183 /// protocol has no response code for "your transmission was evicted".
184 pub const MAX_IN_FLIGHT_TRANSMISSIONS: usize = 16;
185
186 /// Parser for kitty-graphics APC payloads. Feed one APC body at a time via
187 /// [`Parser::feed`]. Returns `Some(Command)` once a chunked transmission
188 /// completes; returns `None` while more chunks are expected or for a chunk
189 /// that couldn't be parsed.
190 #[derive(Debug, Default)]
191 pub struct Parser {
192 partial: HashMap<PartialKey, Partial>,
193 /// Ticks once per chunk accepted, so [`Partial::last_advanced`] orders the
194 /// map for eviction. A `HashMap` has no order of its own and an eviction
195 /// that picked arbitrarily would drop whichever transfer the hasher felt
196 /// like.
197 clock: u64,
198 }
199
200 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
201 enum PartialKey {
202 ById(u32),
203 ByNumber(u32),
204 /// Fallback when the client didn't send an id or number; single-in-flight.
205 Anon,
206 }
207
208 #[derive(Debug)]
209 struct Partial {
210 control: Control,
211 payload: Vec<u8>,
212 /// Set once the transmission has asked for more than
213 /// [`MAX_TRANSMISSION_BYTES`]. Later chunks are parsed and dropped rather
214 /// than buffered, and the completed command is never emitted.
215 ///
216 /// A flag rather than removing the entry, because removing it would leave
217 /// the closing `m=0` chunk looking like a fresh single-shot transmission
218 /// and hand the host a few bytes of tail as a whole image.
219 overflowed: bool,
220 /// The parser's clock when this transmission last took a chunk. The
221 /// smallest is the one eviction takes.
222 last_advanced: u64,
223 }
224
225 impl Parser {
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 /// Transmissions this parser is holding chunks for, at most
231 /// [`MAX_IN_FLIGHT_TRANSMISSIONS`].
232 ///
233 /// Exposed so the soak oracle can assert the ceiling instead of waiting
234 /// for libFuzzer's RSS limit to notice. A client that starts a chunked
235 /// transmission and never finishes it holds an entry until it is evicted.
236 #[must_use]
237 pub fn pending_transmissions(&self) -> usize {
238 self.partial.len()
239 }
240
241 /// Bytes held across all in-flight transmissions: the decoded payloads and
242 /// the map that keys them.
243 ///
244 /// The map's own spine is counted because that is where an attack on the
245 /// id space lands. A client sending one unfinished chunk per `i=` retains
246 /// almost no payload and an entry per id, so a payload-only measure would
247 /// report a few bytes while the process grew by a gigabyte.
248 ///
249 /// Bounded by [`MAX_IN_FLIGHT_BYTES`], but not equal to it: the
250 /// accumulators grow by doubling, so capacity runs ahead of the length the
251 /// budget is checked against, by up to a factor of two.
252 #[must_use]
253 pub fn pending_bytes(&self) -> usize {
254 self.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>()
255 + self
256 .partial
257 .values()
258 .map(|p| p.payload.capacity())
259 .sum::<usize>()
260 }
261
262 /// Consume one APC body (the bytes between `\e_G` and the ST/BEL
263 /// terminator). Bodies have the form `<control>;<base64-payload>` or
264 /// `<control>` alone (place/delete/compose with no payload).
265 pub fn feed(&mut self, body: &[u8]) -> Option<Command> {
266 // The protocol always uses `G` as the introducer. Callers may or may
267 // not have stripped it; tolerate both.
268 let body = body.strip_prefix(b"G").unwrap_or(body);
269
270 let (control_bytes, payload_b64) = split_once_byte(body, b';');
271 let control = parse_control(control_bytes)?;
272
273 // Actions that carry image data require the `;` separator, even if
274 // the payload after it is empty (chunk continuation). Reject header-
275 // only forms of these — a strict, conformant parser wouldn't accept
276 // an `a=T` with no payload marker.
277 let needs_payload = matches!(control.action, 'T' | 't' | 'f');
278 if needs_payload && payload_b64.is_none() {
279 return None;
280 }
281
282 match control.action {
283 // Payload-carrying actions share the chunk-reassembly path. A
284 // continuation chunk parses with the default action ('T'), so
285 // route the completed command using the *stored* first-chunk
286 // action, not the incoming chunk's action.
287 'T' | 't' | 'f' => {
288 let b64 = payload_b64.unwrap_or(&[]);
289 let (control, payload) = self.accept_chunked(control, b64)?;
290 Some(match control.action {
291 'f' => Command::FrameAppend { control, payload },
292 _ => Command::Transmit { control, payload },
293 })
294 }
295 // Payload-less actions. If a `;<payload>` was sent anyway, validate
296 // that it's decodable so a malformed one still errors — preserves
297 // the pre-refactor contract without keeping the decoded bytes.
298 //
299 // A query belongs here despite usually carrying a payload: it is
300 // asking about a shape, not sending an image, so the bytes are
301 // checked and dropped rather than reassembled. Probes send a
302 // single pixel, so there is no chunking to honour either.
303 'p' | 'c' | 'd' | 'q' => {
304 if let Some(b) = payload_b64 {
305 if !b.is_empty() && B64.decode(b).is_err() {
306 return None;
307 }
308 }
309 match control.action {
310 'p' => Some(Command::Place { control }),
311 'c' => Some(Command::FrameCompose { control }),
312 'd' => Some(Command::Delete { control }),
313 'q' => Some(Command::Query { control }),
314 _ => unreachable!(),
315 }
316 }
317 _ => {
318 tracing::trace!("kitty-graphics: unhandled action {}", control.action);
319 None
320 }
321 }
322 }
323
324 /// Reassemble chunked payload for actions that carry image data.
325 /// Returns `Some((control, payload))` when a transmission completes.
326 ///
327 /// Decodes base64 straight into the accumulator to avoid per-chunk
328 /// transient allocation. On decode failure the accumulator is truncated
329 /// back to its pre-call length so a bad chunk doesn't contaminate an
330 /// in-flight transmission.
331 fn accept_chunked(
332 &mut self,
333 control: Control,
334 payload_b64: &[u8],
335 ) -> Option<(Control, Vec<u8>)> {
336 let key = if let Some(id) = control.id {
337 PartialKey::ById(id)
338 } else if let Some(n) = control.number {
339 PartialKey::ByNumber(n)
340 } else {
341 PartialKey::Anon
342 };
343
344 if !control.more_chunks && !self.partial.contains_key(&key) {
345 // Single-shot — no reassembly needed. Decode into a fresh Vec
346 // sized to the payload; the caller owns the result.
347 if over_budget(decoded_upper_bound(payload_b64)) {
348 return None;
349 }
350 let payload = if payload_b64.is_empty() {
351 Vec::new()
352 } else {
353 B64.decode(payload_b64).ok()?
354 };
355 return Some((control, payload));
356 }
357
358 // Opening a new transmission is the moment the map can grow, so it is
359 // the moment eviction has to run.
360 if !self.partial.contains_key(&key) {
361 self.evict_until_room_for_one();
362 }
363
364 self.clock += 1;
365 let clock = self.clock;
366 // Held over the borrow below, because the budget is a property of the
367 // whole map and the entry borrows it mutably.
368 let held_elsewhere = self.in_flight_bytes_excluding(key);
369
370 let entry = self.partial.entry(key).or_insert_with(|| Partial {
371 control: control.clone(),
372 payload: Vec::new(),
373 overflowed: false,
374 last_advanced: clock,
375 });
376 entry.last_advanced = clock;
377
378 if !payload_b64.is_empty() {
379 let start = entry.payload.len();
380 // Upper bound: 3 decoded bytes per 4 base64 chars, rounded up.
381 let extra = decoded_upper_bound(payload_b64);
382 // Before the resize, not after: the arithmetic below runs over a
383 // length the writer chose, and the resize is what commits it.
384 let after = held_elsewhere.saturating_add(start).saturating_add(extra);
385 if entry.overflowed || over_budget(after) {
386 entry.overflowed = true;
387 } else {
388 entry.payload.resize(start + extra, 0);
389 match B64.decode_slice(payload_b64, &mut entry.payload[start..]) {
390 Ok(written) => entry.payload.truncate(start + written),
391 Err(_) => {
392 entry.payload.truncate(start);
393 return None;
394 }
395 }
396 }
397 }
398 // Merge in fields the first chunk didn't carry.
399 merge_control(&mut entry.control, &control);
400
401 if control.more_chunks {
402 None
403 } else {
404 let Partial {
405 control,
406 payload,
407 overflowed,
408 ..
409 } = self.partial.remove(&key)?;
410 // An over-budget transmission is dropped whole. Handing the host
411 // the leading bytes of an image whose header claims more is a
412 // different image, not a smaller one, and the format decoders
413 // downstream would be reading a truncated stream.
414 if overflowed {
415 return None;
416 }
417 Some((control, payload))
418 }
419 }
420
421 /// Decoded bytes held by every in-flight transmission except `key`.
422 ///
423 /// Summed rather than tracked in a counter. The map holds at most
424 /// [`MAX_IN_FLIGHT_TRANSMISSIONS`] entries, so this is sixteen additions
425 /// per chunk, and a counter kept alongside the payloads is one more thing
426 /// that can disagree with them.
427 fn in_flight_bytes_excluding(&self, key: PartialKey) -> usize {
428 self.partial
429 .iter()
430 .filter(|(k, _)| **k != key)
431 .map(|(_, p)| p.payload.len())
432 .sum()
433 }
434
435 /// Drops the least recently advanced transmissions until one more fits.
436 fn evict_until_room_for_one(&mut self) {
437 while self.partial.len() >= MAX_IN_FLIGHT_TRANSMISSIONS {
438 let Some(oldest) = self
439 .partial
440 .iter()
441 .min_by_key(|(_, p)| p.last_advanced)
442 .map(|(k, _)| *k)
443 else {
444 break;
445 };
446 self.partial.remove(&oldest);
447 }
448 // `remove` leaves the table's capacity behind, and the table is what
449 // an attack on the id space grows. Without this a burst of 200,000
450 // distinct ids would leave a 200,000-slot table allocated for the life
451 // of the terminal even though it holds sixteen entries.
452 self.partial.shrink_to_fit();
453 }
454 }
455
456 /// Decoded bytes `payload_b64` could produce: 3 per 4 base64 characters,
457 /// rounded up. An upper bound rather than the exact figure, because it has to
458 /// be known before the decode and it is what the accumulator is sized to.
459 fn decoded_upper_bound(payload_b64: &[u8]) -> usize {
460 payload_b64.len().div_ceil(4) * 3
461 }
462
463 /// Whether `bytes` of decoded payload is more than the in-flight budget allows.
464 ///
465 /// A named decision rather than two inline `>` comparisons, because the
466 /// boundary is only reachable through 89 MB of base64 otherwise: the mutant
467 /// that widens it to `>=` differs from the original at exactly
468 /// [`MAX_IN_FLIGHT_BYTES`] and nowhere else. Here a test can state the boundary
469 /// directly, and both callers get it from one place.
470 fn over_budget(bytes: usize) -> bool {
471 bytes > MAX_IN_FLIGHT_BYTES
472 }
473
474 /// Split at the first `sep`. Returns `(before, None)` when no `sep` byte is
475 /// present — the caller uses that to distinguish "empty payload after `;`"
476 /// from "no `;` at all".
477 fn split_once_byte(bytes: &[u8], sep: u8) -> (&[u8], Option<&[u8]>) {
478 match bytes.iter().position(|&b| b == sep) {
479 Some(i) => (&bytes[..i], Some(&bytes[i + 1..])),
480 None => (bytes, None),
481 }
482 }
483
484 fn parse_control(bytes: &[u8]) -> Option<Control> {
485 let mut c = Control {
486 action: 'T',
487 ..Control::default()
488 };
489 let text = std::str::from_utf8(bytes).ok()?;
490 for kv in text.split(',') {
491 let (k, v) = kv.split_once('=')?;
492 match k {
493 "a" => c.action = v.chars().next()?,
494 "f" => {
495 c.format = match v {
496 "24" => Some(Format::Rgb),
497 "32" => Some(Format::Rgba),
498 "100" => Some(Format::Png),
499 _ => None,
500 }
501 }
502 "t" => {
503 c.medium = match v {
504 "d" => Some(Medium::Direct),
505 "f" => Some(Medium::File),
506 "t" => Some(Medium::TempFile),
507 "s" => Some(Medium::Shm),
508 _ => None,
509 }
510 }
511 "i" => c.id = v.parse().ok(),
512 "I" => c.number = v.parse().ok(),
513 "p" => c.placement = v.parse().ok(),
514 "s" => c.width_px = v.parse().ok(),
515 "v" => c.height_px = v.parse().ok(),
516 "c" => c.cell_cols = v.parse().ok(),
517 "r" => c.cell_rows = v.parse().ok(),
518 "C" => c.no_cursor_move = v == "1",
519 "U" => c.unicode_placeholder = v == "1",
520 "q" => c.quiet = v.parse().unwrap_or(0),
521 "m" => match v {
522 "0" => c.last_chunk = true,
523 "1" => c.more_chunks = true,
524 _ => {}
525 },
526 _ => {}
527 }
528 }
529 if !c.more_chunks {
530 c.last_chunk = true;
531 }
532 Some(c)
533 }
534
535 fn merge_control(into: &mut Control, from: &Control) {
536 if into.format.is_none() {
537 into.format = from.format;
538 }
539 if into.width_px.is_none() {
540 into.width_px = from.width_px;
541 }
542 if into.height_px.is_none() {
543 into.height_px = from.height_px;
544 }
545 if into.cell_cols.is_none() {
546 into.cell_cols = from.cell_cols;
547 }
548 if into.cell_rows.is_none() {
549 into.cell_rows = from.cell_rows;
550 }
551 if from.no_cursor_move {
552 into.no_cursor_move = true;
553 }
554 }
555
556 #[cfg(test)]
557 mod tests {
558 use super::*;
559 use base64::Engine;
560
561 fn b64(bytes: &[u8]) -> String {
562 B64.encode(bytes)
563 }
564
565 fn transmit(cmd: Command) -> (Control, Vec<u8>) {
566 match cmd {
567 Command::Transmit { control, payload } => (control, payload),
568 other => panic!("expected Transmit, got {other:?}"),
569 }
570 }
571
572 // ---- the bounds as numbers, and the accounting that reads them ------
573 //
574 // A cap the parser stays self-consistent under is invisible to every
575 // behavioural test: the parser enforces whatever number is there. These
576 // state the numbers and the arithmetic a second time, which is the only
577 // way a change to either is a disagreement rather than a new baseline.
578
579 #[test]
580 fn the_in_flight_budget_is_sixty_four_mebibytes() {
581 // Written as a plain decimal so that a mutant rewriting the product in
582 // the source has nothing here to agree with.
583 assert_eq!(MAX_IN_FLIGHT_BYTES, 67_108_864);
584 }
585
586 #[test]
587 fn the_budget_boundary_admits_exactly_the_budget() {
588 // `>` vs `>=` disagree at this one value and nowhere else, and the
589 // input that would reach it through feed() is 89 MB of base64.
590 assert!(!over_budget(MAX_IN_FLIGHT_BYTES));
591 assert!(over_budget(MAX_IN_FLIGHT_BYTES + 1));
592 assert!(!over_budget(MAX_IN_FLIGHT_BYTES - 1));
593 }
594
595 /// A parser holding one unfinished chunked transmission of `payload`.
596 fn parser_holding(payload: &[u8]) -> Parser {
597 let mut p = Parser::new();
598 let body = format!("Ga=T,f=32,s=64,v=64,i=7,m=1;{}", b64(payload));
599 assert!(
600 p.feed(body.as_bytes()).is_none(),
601 "an opening chunk emits nothing"
602 );
603 p
604 }
605
606 #[test]
607 fn pending_bytes_counts_the_map_spine_and_every_payload() {
608 let p = parser_holding(&[0xAB; 3000]);
609 // The arithmetic stated twice: a mutant that turns the product into a
610 // sum, or the whole body into a constant, disagrees with this.
611 let expected = p.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>()
612 + p.partial
613 .values()
614 .map(|x| x.payload.capacity())
615 .sum::<usize>();
616 assert_eq!(p.pending_bytes(), expected);
617 assert!(
618 p.pending_bytes() >= 3000,
619 "a transmission holding 3000 bytes cannot report fewer"
620 );
621 }
622
623 #[test]
624 fn in_flight_bytes_excluding_leaves_out_the_named_transmission_only() {
625 let mut p = Parser::new();
626 for (id, len) in [(7u32, 3000usize), (9, 6000)] {
627 let body = format!("Ga=T,f=32,s=64,v=64,i={id},m=1;{}", b64(&vec![0xCD; len]));
628 assert!(p.feed(body.as_bytes()).is_none());
629 }
630 // Two transmissions of different sizes, so excluding the wrong one, or
631 // returning a constant, gives a different answer from all of these.
632 let seven = p.in_flight_bytes_excluding(PartialKey::ById(7));
633 let nine = p.in_flight_bytes_excluding(PartialKey::ById(9));
634 assert_eq!(seven, 6000, "excluding 7 must leave 9's bytes");
635 assert_eq!(nine, 3000, "excluding 9 must leave 7's bytes");
636 assert_eq!(
637 p.in_flight_bytes_excluding(PartialKey::Anon),
638 9000,
639 "excluding a key that is not there leaves both"
640 );
641 }
642
643 #[test]
644 fn the_map_never_holds_more_than_the_in_flight_cap() {
645 // Eviction is what keeps an id-space attack bounded, and nothing else
646 // in the suite makes the map grow past the cap.
647 let mut p = Parser::new();
648 let over = MAX_IN_FLIGHT_TRANSMISSIONS + 24;
649 for id in 0..over {
650 let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x11; 12]));
651 assert!(p.feed(body.as_bytes()).is_none());
652 assert!(
653 p.pending_transmissions() <= MAX_IN_FLIGHT_TRANSMISSIONS,
654 "{} transmissions in flight after opening {}",
655 p.pending_transmissions(),
656 id + 1
657 );
658 }
659 assert_eq!(
660 p.pending_transmissions(),
661 MAX_IN_FLIGHT_TRANSMISSIONS,
662 "{over} openings must leave exactly the cap behind"
663 );
664 }
665
666 // ---- control fields nothing was reading -----------------------------
667
668 #[test]
669 fn every_medium_spelling_parses_to_its_own_variant() {
670 // The media the parser does not act on are still parsed, and a deleted
671 // arm makes one of them read as "no medium given", which the query
672 // answer treats as Direct and says OK to.
673 for (spelling, want) in [
674 ("d", Medium::Direct),
675 ("f", Medium::File),
676 ("t", Medium::TempFile),
677 ("s", Medium::Shm),
678 ] {
679 let c = parse_control(format!("a=q,t={spelling}").as_bytes())
680 .unwrap_or_else(|| panic!("t={spelling} did not parse"));
681 assert_eq!(c.medium, Some(want), "t={spelling}");
682 }
683 assert_eq!(
684 parse_control(b"a=q,t=z").unwrap().medium,
685 None,
686 "an unknown medium is None, not a default"
687 );
688 }
689
690 #[test]
691 fn a_body_with_no_m_field_is_its_own_last_chunk() {
692 // The fallback at the end of parse_control. Without it a single-shot
693 // body reports itself unfinished.
694 let c = parse_control(b"a=T,f=32,s=1,v=1").unwrap();
695 assert!(c.last_chunk, "a body with no m= is complete by itself");
696 assert!(!c.more_chunks);
697 }
698
699 #[test]
700 fn m_zero_sets_last_chunk_even_when_m_one_came_first() {
701 // The only input that separates the `m=0` arm from the fallback: the
702 // fallback cannot fire, because more_chunks is set.
703 let c = parse_control(b"a=T,m=1,m=0").unwrap();
704 assert!(c.last_chunk, "m=0 says last chunk in its own right");
705 assert!(c.more_chunks);
706 }
707
708 #[test]
709 fn the_clock_ticks_once_per_accepted_chunk() {
710 // The clock is what orders the map for eviction. Frozen, every entry
711 // carries the same last_advanced and eviction picks by hash order
712 // instead of by age -- which no assertion about *how many* entries
713 // survive can see.
714 let mut p = Parser::new();
715 for id in 0..5u32 {
716 let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x22; 12]));
717 assert!(p.feed(body.as_bytes()).is_none());
718 }
719 assert_eq!(p.clock, 5, "one tick per chunk accepted, and no more");
720 }
721
722 #[test]
723 fn eviction_drops_the_least_recently_advanced_transmission() {
724 // Not the oldest-opened: a transmission that is still being fed is
725 // live, and dropping it in favour of one that has sat untouched is the
726 // behaviour the clock exists to prevent.
727 let mut p = Parser::new();
728 for id in 0..MAX_IN_FLIGHT_TRANSMISSIONS as u32 {
729 let body = format!("Ga=T,f=32,s=8,v=8,i={id},m=1;{}", b64(&[0x33; 12]));
730 assert!(p.feed(body.as_bytes()).is_none());
731 }
732 // Advance the one opened first, so it is no longer the stalest.
733 assert!(
734 p.feed(format!("Gi=0,m=1;{}", b64(&[0x44; 12])).as_bytes())
735 .is_none()
736 );
737 // Opening one more has to evict, and 1 is now the stalest.
738 assert!(
739 p.feed(format!("Ga=T,f=32,s=8,v=8,i=99,m=1;{}", b64(&[0x55; 12])).as_bytes())
740 .is_none()
741 );
742 assert!(
743 p.partial.contains_key(&PartialKey::ById(0)),
744 "the transmission that was still being fed must survive"
745 );
746 assert!(
747 !p.partial.contains_key(&PartialKey::ById(1)),
748 "the least recently advanced one is what goes"
749 );
750 assert_eq!(p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS);
751 }
752
753 #[test]
754 fn a_later_chunk_can_carry_fields_the_opener_left_out() {
755 // merge_control fills the STORED control from the current chunk, so
756 // this is the direction that observes it: an opener that named no
757 // format, and a closer that does.
758 let payload = [0x6Eu8; 96];
759 let encoded = b64(&payload);
760 let (head, tail) = encoded.split_at(16);
761 let mut p = Parser::new();
762 assert!(p.feed(format!("Ga=T,i=12,m=1;{head}").as_bytes()).is_none());
763 let cmd = p
764 .feed(format!("Gi=12,f=32,s=4,v=8,C=1,m=0;{tail}").as_bytes())
765 .expect("the closing chunk completes the transmission");
766 let (control, got) = transmit(cmd);
767 assert_eq!(got, payload);
768 assert_eq!(
769 control.format,
770 Some(Format::Rgba),
771 "f= arrived on the closing chunk and must be kept"
772 );
773 assert_eq!(control.width_px, Some(4));
774 assert_eq!(control.height_px, Some(8));
775 assert!(control.no_cursor_move);
776 }
777
778 #[test]
779 fn a_continuation_chunk_inherits_the_opening_control() {
780 // merge_control is what carries format and geometry from the opening
781 // chunk to the command; a later chunk repeats only `i=` and `m=`.
782 let payload = [0x7Fu8; 96];
783 let encoded = b64(&payload);
784 let (head, tail) = encoded.split_at(16);
785 let mut p = Parser::new();
786 assert!(
787 p.feed(format!("Ga=T,f=32,s=4,v=8,C=1,i=11,m=1;{head}").as_bytes())
788 .is_none()
789 );
790 let cmd = p
791 .feed(format!("Gi=11,m=0;{tail}").as_bytes())
792 .expect("the closing chunk completes the transmission");
793 let (control, got) = transmit(cmd);
794 assert_eq!(got, payload);
795 assert_eq!(
796 control.format,
797 Some(Format::Rgba),
798 "f= came from the opener"
799 );
800 assert_eq!(control.width_px, Some(4), "s= came from the opener");
801 assert_eq!(control.height_px, Some(8), "v= came from the opener");
802 assert!(control.no_cursor_move, "C= came from the opener");
803 }
804
805 // ---- control-field parsing ---------------------------------------
806
807 #[test]
808 fn header_only_transmit_is_rejected() {
809 // No `;` separator means no payload marker at all. A conformant
810 // parser rejects `a=T` in this shape; earlier versions over-accepted
811 // it as a valid header-only command.
812 let mut p = Parser::new();
813 assert!(p.feed(b"Gf=32,s=1,v=1").is_none());
814 assert!(p.feed(b"Ga=T,f=32,s=1,v=1").is_none());
815 assert!(p.feed(b"Ga=t,f=32,s=1,v=1").is_none());
816 assert!(p.feed(b"Ga=f,f=32,s=1,v=1").is_none());
817 }
818
819 #[test]
820 fn empty_payload_after_semicolon_is_accepted() {
821 // The `;` separator is present but payload is empty — this is a
822 // valid chunk-continuation shape.
823 let mut p = Parser::new();
824 let (control, payload) = transmit(
825 p.feed(b"Gf=32,s=1,v=1;")
826 .expect("empty-payload transmit with `;` must dispatch"),
827 );
828 assert_eq!(control.action, 'T');
829 assert_eq!(control.format, Some(Format::Rgba));
830 assert!(payload.is_empty());
831 }
832
833 #[test]
834 fn parses_full_control_fields() {
835 let mut p = Parser::new();
836 let body = format!(
837 "Ga=T,f=100,t=d,i=42,I=7,p=3,s=100,v=50,c=8,r=4,C=1,q=1;{}",
838 b64(b"data")
839 );
840 let (c, payload) = transmit(p.feed(body.as_bytes()).unwrap());
841 assert_eq!(c.action, 'T');
842 assert_eq!(c.format, Some(Format::Png));
843 assert_eq!(c.medium, Some(Medium::Direct));
844 assert_eq!(c.id, Some(42));
845 assert_eq!(c.number, Some(7));
846 assert_eq!(c.placement, Some(3));
847 assert_eq!(c.width_px, Some(100));
848 assert_eq!(c.height_px, Some(50));
849 assert_eq!(c.cell_cols, Some(8));
850 assert_eq!(c.cell_rows, Some(4));
851 assert!(c.no_cursor_move);
852 assert_eq!(c.quiet, 1);
853 assert_eq!(payload, b"data");
854 }
855
856 #[test]
857 fn tolerates_missing_g_prefix() {
858 // `Parser::feed` strips a leading `G` if present. Callers that
859 // pre-stripped it should still work.
860 let mut p = Parser::new();
861 let body = format!("a=T,f=32,s=1,v=1;{}", b64(b"xyz"));
862 let (control, payload) = transmit(p.feed(body.as_bytes()).unwrap());
863 assert_eq!(control.action, 'T');
864 assert_eq!(payload, b"xyz");
865 }
866
867 fn query_of(body: &[u8]) -> Control {
868 let mut p = Parser::new();
869 match p.feed(body) {
870 Some(Command::Query { control }) => control,
871 other => panic!("expected a query, got {other:?}"),
872 }
873 }
874
875 #[test]
876 fn a_probe_query_parses_as_a_query() {
877 // Verbatim shape of what a client probe sends: one pixel, direct,
878 // 24-bit, asking rather than transmitting.
879 let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
880 assert_eq!(control.action, 'q');
881 assert_eq!(control.id, Some(31));
882 }
883
884 #[test]
885 fn a_query_is_answered_ok_and_addressed_to_its_id() {
886 let control = query_of(b"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA");
887 assert_eq!(query_response(&control), b"\x1b_Gi=31;OK\x1b\\".to_vec());
888 }
889
890 #[test]
891 fn a_query_with_no_id_is_answered_against_zero() {
892 let control = query_of(b"Ga=q,f=32,s=1,v=1;AAAA");
893 assert_eq!(query_response(&control), b"\x1b_Gi=0;OK\x1b\\".to_vec());
894 }
895
896 #[test]
897 fn every_format_shop_decodes_answers_ok() {
898 for body in [
899 b"Ga=q,i=1,f=24,s=1,v=1;AAAA".as_slice(),
900 b"Ga=q,i=1,f=32,s=1,v=1;AAAA".as_slice(),
901 b"Ga=q,i=1,f=100;AAAA".as_slice(),
902 ] {
903 let reply = query_response(&query_of(body));
904 assert_eq!(reply, b"\x1b_Gi=1;OK\x1b\\".to_vec(), "for {body:?}");
905 }
906 }
907
908 #[test]
909 fn a_medium_shop_cannot_read_is_declined_rather_than_ignored() {
910 // Saying OK to a file transfer promises a picture that never
911 // arrives; saying nothing makes the client wait out its timeout.
912 // Both are worse than an honest refusal.
913 let control = query_of(b"Ga=q,i=7,f=100,t=f;L3RtcC94");
914 assert_eq!(
915 query_response(&control),
916 b"\x1b_Gi=7;ENOTSUPP\x1b\\".to_vec()
917 );
918 }
919
920 #[test]
921 fn an_absent_medium_means_direct() {
922 let control = query_of(b"Ga=q,i=2,f=24,s=1,v=1;AAAA");
923 assert_eq!(query_response(&control), b"\x1b_Gi=2;OK\x1b\\".to_vec());
924 }
925
926 #[test]
927 fn a_query_with_no_format_is_declined() {
928 let control = query_of(b"Ga=q,i=3;AAAA");
929 assert_eq!(
930 query_response(&control),
931 b"\x1b_Gi=3;ENOTSUPP\x1b\\".to_vec()
932 );
933 }
934
935 #[test]
936 fn a_query_with_an_undecodable_payload_is_rejected() {
937 let mut p = Parser::new();
938 assert!(p.feed(b"Ga=q,i=1,f=24;!!!!").is_none());
939 }
940
941 #[test]
942 fn a_query_stores_nothing() {
943 // A query must not leave a half-assembled transmission behind for
944 // the next chunk to attach itself to.
945 let mut p = Parser::new();
946 assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
947 assert!(p.feed(b"Gi=31,a=q,f=24,s=1,v=1;AAAA").is_some());
948 }
949
950 #[test]
951 fn unknown_action_returns_none() {
952 let mut p = Parser::new();
953 assert!(p.feed(b"Ga=z,f=32;YWJj").is_none());
954 }
955
956 #[test]
957 fn malformed_control_returns_none() {
958 // No `=` in a field.
959 let mut p = Parser::new();
960 assert!(p.feed(b"Ga=T,broken;YWJj").is_none());
961 }
962
963 #[test]
964 fn malformed_base64_returns_none() {
965 let mut p = Parser::new();
966 assert!(p.feed(b"Ga=T,f=32;this-is-not-base64!!!").is_none());
967 }
968
969 // ---- formats -----------------------------------------------------
970
971 #[test]
972 fn format_24_is_rgb() {
973 let mut p = Parser::new();
974 let body = format!("Ga=T,f=24,s=1,v=1;{}", b64(&[1, 2, 3]));
975 let (c, _) = transmit(p.feed(body.as_bytes()).unwrap());
976 assert_eq!(c.format, Some(Format::Rgb));
977 }
978
979 #[test]
980 fn format_32_is_rgba() {
981 let mut p = Parser::new();
982 let body = format!("Ga=T,f=32,s=1,v=1;{}", b64(&[1, 2, 3, 4]));
983 let (c, _) = transmit(p.feed(body.as_bytes()).unwrap());
984 assert_eq!(c.format, Some(Format::Rgba));
985 }
986
987 #[test]
988 fn format_100_is_png() {
989 let mut p = Parser::new();
990 let body = format!("Ga=T,f=100;{}", b64(b"\x89PNG"));
991 let (c, _) = transmit(p.feed(body.as_bytes()).unwrap());
992 assert_eq!(c.format, Some(Format::Png));
993 }
994
995 // ---- chunked reassembly ------------------------------------------
996
997 #[test]
998 fn single_chunk_no_m_dispatches_immediately() {
999 let mut p = Parser::new();
1000 let body = format!("Ga=T,f=32,s=1,v=1;{}", b64(b"solo"));
1001 let (_, payload) = transmit(p.feed(body.as_bytes()).unwrap());
1002 assert_eq!(payload, b"solo");
1003 }
1004
1005 #[test]
1006 fn two_chunks_reassemble_by_id() {
1007 let mut p = Parser::new();
1008 let a = format!("Ga=T,f=32,s=2,v=1,i=7,m=1;{}", b64(b"HEAD"));
1009 let b = format!("Gi=7,m=0;{}", b64(b"TAIL"));
1010 assert!(
1011 p.feed(a.as_bytes()).is_none(),
1012 "first chunk should not dispatch"
1013 );
1014 let (c, payload) = transmit(p.feed(b.as_bytes()).unwrap());
1015 assert_eq!(c.id, Some(7));
1016 // format from the first chunk must persist through merge.
1017 assert_eq!(c.format, Some(Format::Rgba));
1018 assert_eq!(c.width_px, Some(2));
1019 assert_eq!(payload, b"HEADTAIL");
1020 }
1021
1022 #[test]
1023 fn three_chunks_reassemble() {
1024 let mut p = Parser::new();
1025 let a = format!("Ga=T,f=32,i=1,m=1;{}", b64(b"AAA"));
1026 let b = format!("Gi=1,m=1;{}", b64(b"BBB"));
1027 let c = format!("Gi=1,m=0;{}", b64(b"CCC"));
1028 assert!(p.feed(a.as_bytes()).is_none());
1029 assert!(p.feed(b.as_bytes()).is_none());
1030 let (_, payload) = transmit(p.feed(c.as_bytes()).unwrap());
1031 assert_eq!(payload, b"AAABBBCCC");
1032 }
1033
1034 #[test]
1035 fn concurrent_ids_do_not_cross_contaminate() {
1036 let mut p = Parser::new();
1037 // Interleave two independent chunked transmissions.
1038 let a1 = format!("Ga=T,f=32,i=1,m=1;{}", b64(b"HELLO"));
1039 let b1 = format!("Ga=T,f=32,i=2,m=1;{}", b64(b"WORLD"));
1040 let a2 = format!("Gi=1,m=0;{}", b64(b"!"));
1041 let b2 = format!("Gi=2,m=0;{}", b64(b"?"));
1042
1043 assert!(p.feed(a1.as_bytes()).is_none());
1044 assert!(p.feed(b1.as_bytes()).is_none());
1045 let (ca, pa) = transmit(p.feed(a2.as_bytes()).unwrap());
1046 let (cb, pb) = transmit(p.feed(b2.as_bytes()).unwrap());
1047 assert_eq!(ca.id, Some(1));
1048 assert_eq!(cb.id, Some(2));
1049 assert_eq!(pa, b"HELLO!");
1050 assert_eq!(pb, b"WORLD?");
1051 }
1052
1053 #[test]
1054 fn chunked_by_number_field() {
1055 // Use I= (client-assigned number) instead of i=.
1056 let mut p = Parser::new();
1057 let a = format!("Ga=T,f=32,I=99,m=1;{}", b64(b"XX"));
1058 let b = format!("GI=99,m=0;{}", b64(b"YY"));
1059 assert!(p.feed(a.as_bytes()).is_none());
1060 let (_, payload) = transmit(p.feed(b.as_bytes()).unwrap());
1061 assert_eq!(payload, b"XXYY");
1062 }
1063
1064 // ---- delete ------------------------------------------------------
1065
1066 #[test]
1067 fn delete_returns_delete_command() {
1068 let mut p = Parser::new();
1069 let cmd = p.feed(b"Ga=d,d=A").expect("must dispatch");
1070 assert!(matches!(cmd, Command::Delete { .. }));
1071 }
1072
1073 // ---- place / frame-append / frame-compose ------------------------
1074
1075 #[test]
1076 fn place_at_cursor_returns_place_command() {
1077 let mut p = Parser::new();
1078 let cmd = p
1079 .feed(b"Ga=p,i=1,c=8,r=4")
1080 .expect("place must dispatch header-only");
1081 let Command::Place { control } = cmd else {
1082 panic!("expected Place, got {cmd:?}");
1083 };
1084 assert_eq!(control.action, 'p');
1085 assert_eq!(control.id, Some(1));
1086 assert_eq!(control.cell_cols, Some(8));
1087 assert_eq!(control.cell_rows, Some(4));
1088 assert!(!control.unicode_placeholder);
1089 }
1090
1091 #[test]
1092 fn place_unicode_placeholder_flag_is_parsed() {
1093 let mut p = Parser::new();
1094 let cmd = p
1095 .feed(b"Ga=p,U=1,i=2,q=2")
1096 .expect("virtual placement must dispatch");
1097 let Command::Place { control } = cmd else {
1098 panic!("expected Place, got {cmd:?}");
1099 };
1100 assert!(control.unicode_placeholder);
1101 assert_eq!(control.quiet, 2);
1102 assert_eq!(control.id, Some(2));
1103 }
1104
1105 #[test]
1106 fn frame_append_dispatches_with_payload() {
1107 let mut p = Parser::new();
1108 let body = format!("Ga=f,f=32,s=1,v=1,i=5;{}", b64(b"FRAME"));
1109 let cmd = p.feed(body.as_bytes()).expect("frame append must dispatch");
1110 let Command::FrameAppend { control, payload } = cmd else {
1111 panic!("expected FrameAppend, got {cmd:?}");
1112 };
1113 assert_eq!(control.action, 'f');
1114 assert_eq!(control.id, Some(5));
1115 assert_eq!(payload, b"FRAME");
1116 }
1117
1118 #[test]
1119 fn frame_append_reassembles_chunks() {
1120 let mut p = Parser::new();
1121 let a = format!("Ga=f,f=32,i=9,m=1;{}", b64(b"HEAD"));
1122 let b = format!("Gi=9,m=0;{}", b64(b"TAIL"));
1123 assert!(p.feed(a.as_bytes()).is_none());
1124 let cmd = p.feed(b.as_bytes()).expect("must dispatch on last chunk");
1125 let Command::FrameAppend { control, payload } = cmd else {
1126 panic!("expected FrameAppend, got {cmd:?}");
1127 };
1128 assert_eq!(control.id, Some(9));
1129 assert_eq!(payload, b"HEADTAIL");
1130 }
1131
1132 #[test]
1133 fn frame_compose_dispatches_header_only() {
1134 let mut p = Parser::new();
1135 let cmd = p
1136 .feed(b"Ga=c,i=3,r=1,c=2")
1137 .expect("frame compose must dispatch header-only");
1138 let Command::FrameCompose { control } = cmd else {
1139 panic!("expected FrameCompose, got {cmd:?}");
1140 };
1141 assert_eq!(control.action, 'c');
1142 assert_eq!(control.id, Some(3));
1143 }
1144
1145 // ---- Bounded in-flight state (the 2026-08-29 DoS finding) ----------
1146
1147 /// A client that opens transmissions and never finishes them holds at most
1148 /// [`MAX_IN_FLIGHT_TRANSMISSIONS`], however many ids it invents.
1149 #[test]
1150 fn distinct_ids_do_not_accumulate_without_bound() {
1151 let mut p = Parser::new();
1152 for i in 0..50_000u32 {
1153 let body = format!("Ga=T,f=32,i={i},m=1;{}", b64(b"ABC"));
1154 assert!(p.feed(body.as_bytes()).is_none());
1155 }
1156 assert_eq!(p.pending_transmissions(), MAX_IN_FLIGHT_TRANSMISSIONS);
1157 assert!(
1158 p.pending_bytes() < 8 * 1024,
1159 "parser kept {} bytes for 50k ids",
1160 p.pending_bytes()
1161 );
1162 }
1163
1164 /// Eviction takes the least recently advanced transfer, so the one a
1165 /// client is actively feeding survives a burst of noise beside it.
1166 #[test]
1167 fn eviction_keeps_the_transmission_being_advanced() {
1168 let mut p = Parser::new();
1169 let head = format!("Ga=T,f=32,i=7,m=1;{}", b64(b"HEAD"));
1170 assert!(p.feed(head.as_bytes()).is_none());
1171
1172 for round in 0..4 {
1173 for i in 100..100 + MAX_IN_FLIGHT_TRANSMISSIONS as u32 - 1 {
1174 let noise = format!("Ga=T,f=32,i={},m=1;{}", i + round * 1000, b64(b"NN"));
1175 p.feed(noise.as_bytes());
1176 }
1177 // Advancing id 7 refreshes it, which is what keeps it alive.
1178 let more = format!("Gi=7,m=1;{}", b64(b"-"));
1179 p.feed(more.as_bytes());
1180 }
1181
1182 let tail = format!("Gi=7,m=0;{}", b64(b"TAIL"));
1183 let (control, payload) = transmit(p.feed(tail.as_bytes()).expect("id 7 survived"));
1184 assert_eq!(control.id, Some(7));
1185 assert_eq!(payload, b"HEAD----TAIL");
1186 }
1187
1188 /// A transmission past the byte budget is dropped whole rather than handed
1189 /// over truncated, and its closing chunk does not read as a fresh
1190 /// single-shot image.
1191 #[test]
1192 fn a_transmission_past_the_budget_is_dropped_whole() {
1193 let mut p = Parser::new();
1194 // One chunk that alone asks for more than the budget.
1195 let huge = b64(&vec![0u8; 4096]);
1196 let first = format!("Ga=T,f=32,i=1,m=1;{huge}");
1197 assert!(p.feed(first.as_bytes()).is_none());
1198
1199 let oversized = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 * 4 + 8);
1200 let second = format!("Gi=1,m=1;{oversized}");
1201 assert!(p.feed(second.as_bytes()).is_none());
1202
1203 let tail = format!("Gi=1,m=0;{}", b64(b"TAIL"));
1204 assert!(
1205 p.feed(tail.as_bytes()).is_none(),
1206 "an over-budget transmission must not complete"
1207 );
1208 assert_eq!(p.pending_transmissions(), 0, "and must not linger");
1209 }
1210
1211 /// A single-shot body over the budget is refused rather than decoded.
1212 #[test]
1213 fn a_single_shot_past_the_budget_is_refused() {
1214 let mut p = Parser::new();
1215 let oversized = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 * 4 + 8);
1216 let body = format!("Ga=T,f=32,i=1;{oversized}");
1217 assert!(p.feed(body.as_bytes()).is_none());
1218 assert_eq!(p.pending_transmissions(), 0);
1219 }
1220
1221 /// The budget is shared, so many transmissions cannot each claim it.
1222 #[test]
1223 fn the_byte_budget_is_shared_across_transmissions() {
1224 let mut p = Parser::new();
1225 // Roughly a third of the budget per transmission, three times over.
1226 let third = "A".repeat(MAX_IN_FLIGHT_BYTES / 3 / 3 * 4);
1227 for i in 0..3u32 {
1228 let body = format!("Ga=T,f=32,i={i},m=1;{third}");
1229 assert!(p.feed(body.as_bytes()).is_none());
1230 }
1231 assert!(
1232 p.pending_bytes() <= MAX_IN_FLIGHT_BYTES * 2,
1233 "in-flight bytes reached {}",
1234 p.pending_bytes()
1235 );
1236 }
1237 }
1238