Skip to main content

subetha_cxc/
control_frame.rs

1//! The control plane as a QUIC-style frame container.
2//!
3//! A single `CONTROL` datagram carries a sequence of type-tagged,
4//! length-prefixed frames. Both endpoints emit `CONTROL` datagrams holding
5//! whatever frames they have to report, so the channel is symmetric: an ACK
6//! from the receiver and a TIMING beat from the sender are the same packet
7//! shape, just different frames.
8//!
9//! The point of the framing is extensibility without a version bump. A new
10//! between-endpoint signal - a hop-count delta, an ECN-CE marking, a peer's
11//! link class - becomes a new [`FrameType`], not a new fixed layout. Frames
12//! a peer does not recognize are length-skipped, so old and new builds
13//! interoperate by ignoring each other's unknown frames rather than
14//! mis-parsing the rest of the packet.
15//!
16//! Wire shape:
17//!
18//! ```text
19//! [PKT_CONTROL] ( [frame_type:u8] [length:varint] [payload: length bytes] )*
20//! ```
21//!
22//! Integers wider than a byte use the QUIC variable-length encoding
23//! (RFC 9000 section 16): the top two bits of the first byte select a
24//! 1/2/4/8-byte form, so a small value costs one byte. Byte-sized fields are
25//! written raw. The codec is pure and does no I/O, so it is exhaustively
26//! testable against synthetic frame sequences.
27
28/// Packet-type tag for a control datagram (vs `PKT_DATA`). Distinct from the
29/// retired fixed `PKT_FEEDBACK` / `PKT_HEARTBEAT` tags, which this container
30/// subsumes.
31pub const PKT_CONTROL: u8 = 4;
32
33/// Frame type tags. Stable on the wire; append new variants, never renumber.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum FrameType {
37    /// Cumulative ack frontier (receiver -> sender).
38    Ack = 0x01,
39    /// Selective negative ack: a block and its missing-shard bitmap.
40    Nak = 0x02,
41    /// Fused loss / burstiness / delay-trend readings (receiver -> sender).
42    Loss = 0x03,
43    /// Sender clock beat plus the peer beat being echoed, for RTT and OWD.
44    Timing = 0x04,
45    /// Source-ring shape telemetry (the legacy heartbeat payload).
46    Ring = 0x05,
47    /// The peer's observed TTL / ECN / hop-count of THIS endpoint's packets.
48    Path = 0x06,
49    /// The peer's link class and normalized quality.
50    Link = 0x07,
51    /// Highest peer sequence the sender of this frame has seen, for
52    /// bidirectional (forward vs reverse) loss accounting.
53    LossAcct = 0x08,
54    /// The peer's observed path MTU.
55    Pmtu = 0x09,
56    /// One member of an active bandwidth probe train (packet-pair / chirp).
57    BwProbe = 0x0A,
58    /// A mini-traceroute marker riding the control stream at a chosen TTL.
59    Trace = 0x0B,
60    /// The receiver's WBest available-bandwidth estimate (reverse-reported so the
61    /// sender can cross-check its passive BtlBw).
62    AvailBw = 0x0C,
63    /// The receiver's Sprout-style forecast of the next-tick deliverable rate
64    /// (5th-percentile lower bound), so the sender pre-sizes ahead of a dip.
65    Forecast = 0x0D,
66    /// The receiver's detected LEO handover cadence and seconds-to-next-spike, so
67    /// the sender pre-arms protection one cycle ahead of a periodic delay spike.
68    Periodicity = 0x0E,
69    /// Receiver -> sender: prove you can receive at the address a datagram
70    /// under an unrecognised session epoch came from.
71    SessionChallenge = 0x0F,
72    /// Sender -> receiver: the challenge echoed back.
73    SessionResponse = 0x10,
74    /// The epoch of the session this endpoint is sending under, and on
75    /// feedback the session it describes. Rides the heartbeat, so it
76    /// reaches a peer whose data is being discarded.
77    SessionAnnounce = 0x11,
78}
79
80impl FrameType {
81    /// Map a wire tag to a known frame type, or `None` for an unrecognized
82    /// (length-skippable) frame.
83    fn from_u8(v: u8) -> Option<Self> {
84        Some(match v {
85            0x01 => Self::Ack,
86            0x02 => Self::Nak,
87            0x03 => Self::Loss,
88            0x04 => Self::Timing,
89            0x05 => Self::Ring,
90            0x06 => Self::Path,
91            0x07 => Self::Link,
92            0x08 => Self::LossAcct,
93            0x09 => Self::Pmtu,
94            0x0A => Self::BwProbe,
95            0x0B => Self::Trace,
96            0x0C => Self::AvailBw,
97            0x0D => Self::Forecast,
98            0x0E => Self::Periodicity,
99            0x0F => Self::SessionChallenge,
100            0x10 => Self::SessionResponse,
101            0x11 => Self::SessionAnnounce,
102            _ => return None,
103        })
104    }
105}
106
107/// A session-epoch challenge and the answer echoing it: the epoch under
108/// challenge, and a nonce only a peer that received the challenge holds.
109///
110/// `nonce` MUST fit 62 bits, masked with [`NONCE_MASK`]. The varint codec
111/// clamps wider values, and a clamped echo never compares equal to the
112/// unclamped original.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub struct SessionFrame {
115    pub epoch: u32,
116    pub nonce: u64,
117}
118
119/// Mask a nonce into the 62 bits a varint carries without clamping.
120pub const NONCE_MASK: u64 = (1u64 << 62) - 1;
121
122/// Cumulative ack frontier: the next block the receiver still needs.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124pub struct AckFrame {
125    pub ack_through: u32,
126}
127
128/// Selective NAK: which shards of which block are still missing.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub struct NakFrame {
131    pub block: u32,
132    pub mask: u32,
133}
134
135/// Fused channel readings the receiver reports to the sender's controller.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
137pub struct LossFrame {
138    pub loss_x255: u8,
139    pub burstiness_x255: u8,
140    pub owd_trend_class: u8,
141    /// Loss-class code (0 = no loss, 1 = wireless, 2 = congestion, 3 = mixed)
142    /// from the receiver's [`crate::loss_class_sensor`], so the sender's
143    /// controller can treat congestion and wireless loss differently.
144    pub loss_class: u8,
145}
146
147/// Sender clock beat. `echo_ts` reflects the peer's last `send_ts` back, so
148/// either end can compute RTT; `send_ts` alone drives the OWD-trend slope.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub struct TimingFrame {
151    pub send_ts: u64,
152    pub echo_ts: u64,
153}
154
155/// Source-ring shape telemetry: the legacy heartbeat payload, now a frame.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
157pub struct RingFrame {
158    pub fill_pct: u8,
159    pub ring_kind: u8,
160    pub producers: u8,
161    pub consumers: u8,
162    pub trend: u8,
163    pub flags: u8,
164}
165
166/// The peer's view of THIS endpoint's packets: the TTL it saw, the ECN bits,
167/// and the hop count it derived from the TTL. A change in `hop_count` is a
168/// router-level path shift, often visible before throughput moves.
169///
170/// AccECN (item 15): `ce_count` / `ect_count` are the peer's CUMULATIVE counts of
171/// our CE-marked and ECN-capable packets, so the sender derives a graded
172/// `ce_rate = delta_CE / delta_ECT` between frames instead of reading a single
173/// CE bit. An AQM marks CE before it tail-drops, so a rising rate leads loss.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub struct PathFrame {
176    pub ttl: u8,
177    pub ecn: u8,
178    pub hop_count: u8,
179    pub ce_count: u64,
180    pub ect_count: u64,
181}
182
183/// The peer's link class and a normalized 0..=255 quality (RSSI / RSRP /
184/// link-rate). A class change (wifi -> cellular) is a handoff announcement.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
186pub struct LinkFrame {
187    pub class: u8,
188    pub quality: u8,
189}
190
191/// Link-class enum carried in [`LinkFrame::class`].
192pub mod link_class {
193    pub const UNKNOWN: u8 = 0;
194    pub const LOOPBACK: u8 = 1;
195    pub const WIRED: u8 = 2;
196    pub const WIFI: u8 = 3;
197    pub const CELLULAR: u8 = 4;
198}
199
200/// Bidirectional control-plane loss accounting. `seq` is the count of control
201/// packets this endpoint has SENT; `last_recv_seq` is the count it has RECEIVED
202/// from the peer. Pairing the two separates forward-path loss (the peer did not
203/// get your packets: your `seq` minus the peer's reported `last_recv_seq`) from
204/// reverse-path loss (you did not get the peer's: the peer's `seq` minus your
205/// `last_recv_seq`).
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub struct LossAcctFrame {
208    pub seq: u32,
209    pub last_recv_seq: u32,
210}
211
212/// The peer's observed path MTU. A drop (1500 -> ~1280) flags a lower-MTU
213/// link engaging, e.g. a cellular handoff; the frame size should track it.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub struct PmtuFrame {
216    pub pmtu: u16,
217}
218
219/// One member of a bandwidth-probe train. The receiver measures inter-arrival
220/// dilation across a train sharing `probe_id` to estimate available bandwidth.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub struct BwProbeFrame {
223    pub probe_id: u8,
224    pub idx: u8,
225    pub send_ts: u64,
226}
227
228/// A mini-traceroute marker: a control packet emitted at a reduced IP TTL so
229/// an intermediate router replies with ICMP TimeExceeded, exposing per-hop
230/// RTT without a separate probe flow.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
232pub struct TraceFrame {
233    pub hop_ttl: u8,
234    pub probe_id: u8,
235}
236
237/// The receiver's WBest available-bandwidth estimate, reverse-reported so the
238/// sender can cross-check its passive BtlBw. Carried in kbit/s so a multi-Gbit
239/// estimate fits a varint without floating point on the wire.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
241pub struct AvailBwFrame {
242    /// Available bandwidth in kbit/s (0 = no estimate yet).
243    pub avail_kbps: u64,
244    /// Effective capacity in kbit/s (the WBest stage-1 median), for the
245    /// cross-check against the passive BtlBw.
246    pub capacity_kbps: u64,
247}
248
249/// The receiver's Sprout-style forecast (item 16): the 5th-percentile deliverable
250/// rate it predicts for the next tick, so the sender pre-sizes its window ahead
251/// of a dip instead of reacting after the loss the dip causes.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
253pub struct ForecastFrame {
254    /// Forecast deliverable rate in kbit/s (0 = no forecast yet).
255    pub forecast_kbps: u64,
256}
257
258/// The receiver's LEO handover-cadence detection (item 17): the detected period
259/// and the time to the next predicted delay spike, both in deciseconds (0.1 s),
260/// plus a confidence, so the sender pre-arms one cycle ahead. `period_ds == 0`
261/// means no cadence detected.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
263pub struct PeriodicityFrame {
264    pub period_ds: u64,
265    pub secs_to_spike_ds: u64,
266    pub confidence_x255: u8,
267}
268
269/// A decoded control packet: every frame is optional, so a packet carries
270/// exactly the signals its sender had to report. Probe and trace frames may
271/// repeat (a train), so they are collected.
272#[derive(Debug, Clone, Default, PartialEq, Eq)]
273pub struct ControlPacket {
274    pub ack: Option<AckFrame>,
275    pub nak: Option<NakFrame>,
276    pub loss: Option<LossFrame>,
277    pub timing: Option<TimingFrame>,
278    pub ring: Option<RingFrame>,
279    pub path: Option<PathFrame>,
280    pub link: Option<LinkFrame>,
281    pub loss_acct: Option<LossAcctFrame>,
282    pub pmtu: Option<PmtuFrame>,
283    pub bw_probe: Vec<BwProbeFrame>,
284    pub trace: Vec<TraceFrame>,
285    pub avail_bw: Option<AvailBwFrame>,
286    pub forecast: Option<ForecastFrame>,
287    pub periodicity: Option<PeriodicityFrame>,
288    pub session_challenge: Option<SessionFrame>,
289    pub session_response: Option<SessionFrame>,
290    /// The epoch the sender is currently sending under.
291    pub session_announce: Option<u32>,
292}
293
294impl ControlPacket {
295    /// An empty packet (no frames).
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// `true` if the packet carries no frames at all (nothing to send).
301    pub fn is_empty(&self) -> bool {
302        self.ack.is_none()
303            && self.nak.is_none()
304            && self.loss.is_none()
305            && self.timing.is_none()
306            && self.ring.is_none()
307            && self.path.is_none()
308            && self.link.is_none()
309            && self.loss_acct.is_none()
310            && self.pmtu.is_none()
311            && self.bw_probe.is_empty()
312            && self.trace.is_empty()
313    }
314}
315
316/// `true` if `buf` is a control datagram.
317pub fn is_control(buf: &[u8]) -> bool {
318    !buf.is_empty() && buf[0] == PKT_CONTROL
319}
320
321// --- QUIC variable-length integer codec (RFC 9000 section 16) ---
322
323/// Append `v` to `out` in the smallest QUIC varint form. Values must fit 62
324/// bits (every field here does); a wider value is clamped to the 62-bit max
325/// rather than corrupting the stream.
326fn put_varint(out: &mut Vec<u8>, v: u64) {
327    const MAX62: u64 = (1 << 62) - 1;
328    let v = v.min(MAX62);
329    if v < (1 << 6) {
330        out.push(v as u8);
331    } else if v < (1 << 14) {
332        out.push(0x40 | (v >> 8) as u8);
333        out.push(v as u8);
334    } else if v < (1 << 30) {
335        out.push(0x80 | (v >> 24) as u8);
336        out.extend_from_slice(&(v as u32).to_be_bytes()[1..]);
337    } else {
338        out.push(0xC0 | (v >> 56) as u8);
339        out.extend_from_slice(&v.to_be_bytes()[1..]);
340    }
341}
342
343/// Read a QUIC varint at `pos`, returning `(value, next_pos)` or `None` if
344/// the buffer is too short for the encoded length.
345fn get_varint(buf: &[u8], pos: usize) -> Option<(u64, usize)> {
346    let first = *buf.get(pos)?;
347    let len = 1usize << (first >> 6);
348    if pos + len > buf.len() {
349        return None;
350    }
351    let mut v = (first & 0x3F) as u64;
352    for &b in &buf[pos + 1..pos + len] {
353        v = (v << 8) | b as u64;
354    }
355    Some((v, pos + len))
356}
357
358// --- frame body helpers: write a [type][len][payload] frame into `out` ---
359
360/// Write one frame: its type tag, the varint length of `body`, then `body`.
361fn put_frame(out: &mut Vec<u8>, ty: FrameType, body: &[u8]) {
362    out.push(ty as u8);
363    put_varint(out, body.len() as u64);
364    out.extend_from_slice(body);
365}
366
367/// Pad an encoded control datagram up to `target_len` bytes by appending one
368/// unknown-type frame (which the decoder length-skips). An active bandwidth
369/// probe rides a known, large datagram so its inter-arrival dispersion is a
370/// capacity measurement at that packet size; this is how it reaches that size
371/// without inventing a payload the peer must understand. No-op when the gap is
372/// too small to hold the padding frame's 3-byte header plus a 64-byte body
373/// (the threshold that keeps the length varint exactly two bytes, so the final
374/// datagram is exactly `target_len`).
375pub fn pad_control_to(buf: &mut Vec<u8>, target_len: usize) {
376    const PAD_TYPE: u8 = 0x7F;
377    const HEADER: usize = 3; // PAD_TYPE + a 2-byte varint length
378    if target_len < buf.len() + HEADER + 64 {
379        return;
380    }
381    let body_len = target_len - buf.len() - HEADER;
382    buf.push(PAD_TYPE);
383    put_varint(buf, body_len as u64);
384    buf.resize(buf.len() + body_len, 0);
385}
386
387/// Encode a control packet into a fresh datagram buffer.
388pub fn encode_control(p: &ControlPacket) -> Vec<u8> {
389    let mut out = Vec::with_capacity(64);
390    out.push(PKT_CONTROL);
391    let mut body = Vec::with_capacity(16);
392
393    if let Some(f) = p.ack {
394        body.clear();
395        put_varint(&mut body, f.ack_through as u64);
396        put_frame(&mut out, FrameType::Ack, &body);
397    }
398    if let Some(f) = p.nak {
399        body.clear();
400        put_varint(&mut body, f.block as u64);
401        put_varint(&mut body, f.mask as u64);
402        put_frame(&mut out, FrameType::Nak, &body);
403    }
404    if let Some(f) = p.loss {
405        put_frame(
406            &mut out,
407            FrameType::Loss,
408            &[f.loss_x255, f.burstiness_x255, f.owd_trend_class, f.loss_class],
409        );
410    }
411    if let Some(f) = p.timing {
412        body.clear();
413        put_varint(&mut body, f.send_ts);
414        put_varint(&mut body, f.echo_ts);
415        put_frame(&mut out, FrameType::Timing, &body);
416    }
417    if let Some(f) = p.ring {
418        put_frame(
419            &mut out,
420            FrameType::Ring,
421            &[
422                f.fill_pct,
423                f.ring_kind,
424                f.producers,
425                f.consumers,
426                f.trend,
427                f.flags,
428            ],
429        );
430    }
431    if let Some(f) = p.path {
432        body.clear();
433        body.extend_from_slice(&[f.ttl, f.ecn, f.hop_count]);
434        put_varint(&mut body, f.ce_count);
435        put_varint(&mut body, f.ect_count);
436        put_frame(&mut out, FrameType::Path, &body);
437    }
438    if let Some(f) = p.link {
439        put_frame(&mut out, FrameType::Link, &[f.class, f.quality]);
440    }
441    if let Some(f) = p.loss_acct {
442        body.clear();
443        put_varint(&mut body, f.seq as u64);
444        put_varint(&mut body, f.last_recv_seq as u64);
445        put_frame(&mut out, FrameType::LossAcct, &body);
446    }
447    if let Some(f) = p.pmtu {
448        body.clear();
449        put_varint(&mut body, f.pmtu as u64);
450        put_frame(&mut out, FrameType::Pmtu, &body);
451    }
452    for f in &p.bw_probe {
453        body.clear();
454        body.push(f.probe_id);
455        body.push(f.idx);
456        put_varint(&mut body, f.send_ts);
457        put_frame(&mut out, FrameType::BwProbe, &body);
458    }
459    for f in &p.trace {
460        put_frame(&mut out, FrameType::Trace, &[f.hop_ttl, f.probe_id]);
461    }
462    if let Some(f) = p.avail_bw {
463        body.clear();
464        put_varint(&mut body, f.avail_kbps);
465        put_varint(&mut body, f.capacity_kbps);
466        put_frame(&mut out, FrameType::AvailBw, &body);
467    }
468    if let Some(f) = p.forecast {
469        body.clear();
470        put_varint(&mut body, f.forecast_kbps);
471        put_frame(&mut out, FrameType::Forecast, &body);
472    }
473    if let Some(f) = p.periodicity {
474        body.clear();
475        put_varint(&mut body, f.period_ds);
476        put_varint(&mut body, f.secs_to_spike_ds);
477        body.push(f.confidence_x255);
478        put_frame(&mut out, FrameType::Periodicity, &body);
479    }
480    if let Some(f) = p.session_challenge {
481        body.clear();
482        put_varint(&mut body, u64::from(f.epoch));
483        put_varint(&mut body, f.nonce);
484        put_frame(&mut out, FrameType::SessionChallenge, &body);
485    }
486    if let Some(f) = p.session_response {
487        body.clear();
488        put_varint(&mut body, u64::from(f.epoch));
489        put_varint(&mut body, f.nonce);
490        put_frame(&mut out, FrameType::SessionResponse, &body);
491    }
492    if let Some(epoch) = p.session_announce {
493        body.clear();
494        put_varint(&mut body, u64::from(epoch));
495        put_frame(&mut out, FrameType::SessionAnnounce, &body);
496    }
497    out
498}
499
500/// Decode a control datagram. Unknown frame types are length-skipped; a
501/// frame whose declared length runs past the buffer aborts the parse and
502/// returns whatever was decoded up to that point. Returns `None` only if the
503/// packet is not a control datagram.
504pub fn decode_control(buf: &[u8]) -> Option<ControlPacket> {
505    if !is_control(buf) {
506        return None;
507    }
508    let mut p = ControlPacket::new();
509    let mut pos = 1usize;
510    while pos < buf.len() {
511        let ty = buf[pos];
512        pos += 1;
513        let (len, next) = match get_varint(buf, pos) {
514            Some(v) => v,
515            None => break,
516        };
517        pos = next;
518        let end = pos + len as usize;
519        if end > buf.len() {
520            break;
521        }
522        let body = &buf[pos..end];
523        match FrameType::from_u8(ty) {
524            Some(FrameType::Ack) => {
525                if let Some((v, _)) = get_varint(body, 0) {
526                    p.ack = Some(AckFrame {
527                        ack_through: v as u32,
528                    });
529                }
530            }
531            Some(FrameType::Nak) => {
532                if let Some((block, q)) = get_varint(body, 0)
533                    && let Some((mask, _)) = get_varint(body, q)
534                {
535                    p.nak = Some(NakFrame {
536                        block: block as u32,
537                        mask: mask as u32,
538                    });
539                }
540            }
541            Some(FrameType::Loss) if body.len() >= 3 => {
542                p.loss = Some(LossFrame {
543                    loss_x255: body[0],
544                    burstiness_x255: body[1],
545                    owd_trend_class: body[2],
546                    // Tolerate a 3-byte Loss frame (loss_class absent -> 0), so
547                    // the codec stays forward-compatible like the frame skip.
548                    loss_class: body.get(3).copied().unwrap_or(0),
549                });
550            }
551            Some(FrameType::Timing) => {
552                if let Some((send_ts, q)) = get_varint(body, 0)
553                    && let Some((echo_ts, _)) = get_varint(body, q)
554                {
555                    p.timing = Some(TimingFrame { send_ts, echo_ts });
556                }
557            }
558            Some(FrameType::Ring) if body.len() >= 6 => {
559                p.ring = Some(RingFrame {
560                    fill_pct: body[0],
561                    ring_kind: body[1],
562                    producers: body[2],
563                    consumers: body[3],
564                    trend: body[4],
565                    flags: body[5],
566                });
567            }
568            Some(FrameType::Path) if body.len() >= 3 => {
569                // The two AccECN counters are optional varints after the fixed
570                // three bytes (a peer that does not send them reads as 0).
571                let (ce_count, n1) = get_varint(body, 3).unwrap_or((0, 3));
572                let (ect_count, _) = get_varint(body, n1).unwrap_or((0, n1));
573                p.path = Some(PathFrame {
574                    ttl: body[0],
575                    ecn: body[1],
576                    hop_count: body[2],
577                    ce_count,
578                    ect_count,
579                });
580            }
581            Some(FrameType::Link) if body.len() >= 2 => {
582                p.link = Some(LinkFrame {
583                    class: body[0],
584                    quality: body[1],
585                });
586            }
587            Some(FrameType::LossAcct) => {
588                if let Some((seq, n)) = get_varint(body, 0)
589                    && let Some((lrs, _)) = get_varint(body, n)
590                {
591                    p.loss_acct = Some(LossAcctFrame {
592                        seq: seq as u32,
593                        last_recv_seq: lrs as u32,
594                    });
595                }
596            }
597            Some(FrameType::Pmtu) => {
598                if let Some((v, _)) = get_varint(body, 0) {
599                    p.pmtu = Some(PmtuFrame { pmtu: v as u16 });
600                }
601            }
602            Some(FrameType::BwProbe) if body.len() >= 2 => {
603                if let Some((send_ts, _)) = get_varint(body, 2) {
604                    p.bw_probe.push(BwProbeFrame {
605                        probe_id: body[0],
606                        idx: body[1],
607                        send_ts,
608                    });
609                }
610            }
611            Some(FrameType::Trace) if body.len() >= 2 => {
612                p.trace.push(TraceFrame {
613                    hop_ttl: body[0],
614                    probe_id: body[1],
615                });
616            }
617            Some(FrameType::AvailBw) => {
618                if let Some((avail, n)) = get_varint(body, 0)
619                    && let Some((cap, _)) = get_varint(body, n)
620                {
621                    p.avail_bw = Some(AvailBwFrame {
622                        avail_kbps: avail,
623                        capacity_kbps: cap,
624                    });
625                }
626            }
627            Some(FrameType::SessionAnnounce) => {
628                if let Some((epoch, _)) = get_varint(body, 0) {
629                    p.session_announce = Some(epoch as u32);
630                }
631            }
632            Some(FrameType::SessionChallenge) => {
633                if let Some((epoch, n)) = get_varint(body, 0)
634                    && let Some((nonce, _)) = get_varint(body, n)
635                {
636                    p.session_challenge = Some(SessionFrame { epoch: epoch as u32, nonce });
637                }
638            }
639            Some(FrameType::SessionResponse) => {
640                if let Some((epoch, n)) = get_varint(body, 0)
641                    && let Some((nonce, _)) = get_varint(body, n)
642                {
643                    p.session_response = Some(SessionFrame { epoch: epoch as u32, nonce });
644                }
645            }
646            Some(FrameType::Forecast) => {
647                if let Some((fc, _)) = get_varint(body, 0) {
648                    p.forecast = Some(ForecastFrame { forecast_kbps: fc });
649                }
650            }
651            Some(FrameType::Periodicity) => {
652                if let Some((period, n1)) = get_varint(body, 0)
653                    && let Some((to_spike, n2)) = get_varint(body, n1)
654                    && n2 < body.len()
655                {
656                    p.periodicity = Some(PeriodicityFrame {
657                        period_ds: period,
658                        secs_to_spike_ds: to_spike,
659                        confidence_x255: body[n2],
660                    });
661                }
662            }
663            // Known-but-malformed (too short) or unknown frame: length-skip.
664            _ => {}
665        }
666        pos = end;
667    }
668    Some(p)
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn varint_round_trips_each_length_class() {
677        for v in [0u64, 1, 63, 64, 16383, 16384, (1 << 30) - 1, 1 << 30, (1u64 << 62) - 1] {
678            let mut b = Vec::new();
679            put_varint(&mut b, v);
680            let (got, end) = get_varint(&b, 0).expect("decode");
681            assert_eq!(got, v, "value {v} round-trip");
682            assert_eq!(end, b.len(), "consumed all bytes for {v}");
683        }
684    }
685
686    #[test]
687    fn varint_uses_minimal_encoding() {
688        let mut b = Vec::new();
689        put_varint(&mut b, 63);
690        assert_eq!(b.len(), 1, "6-bit value is one byte");
691        b.clear();
692        put_varint(&mut b, 64);
693        assert_eq!(b.len(), 2, "14-bit value is two bytes");
694    }
695
696    #[test]
697    fn full_packet_round_trips_every_frame() {
698        let p = ControlPacket {
699            ack: Some(AckFrame { ack_through: 70_000 }),
700            nak: Some(NakFrame {
701                block: 12,
702                mask: 0b1011,
703            }),
704            loss: Some(LossFrame {
705                loss_x255: 40,
706                burstiness_x255: 200,
707                owd_trend_class: 2,
708                loss_class: 2,
709            }),
710            timing: Some(TimingFrame {
711                send_ts: 1_234_567,
712                echo_ts: 1_234_000,
713            }),
714            ring: Some(RingFrame {
715                fill_pct: 30,
716                ring_kind: 1,
717                producers: 2,
718                consumers: 3,
719                trend: 1,
720                flags: 1,
721            }),
722            path: Some(PathFrame {
723                ttl: 53,
724                ecn: 0b11,
725                hop_count: 11,
726                ce_count: 4242,
727                ect_count: 99999,
728            }),
729            link: Some(LinkFrame {
730                class: link_class::WIFI,
731                quality: 180,
732            }),
733            loss_acct: Some(LossAcctFrame {
734                seq: 6000,
735                last_recv_seq: 5000,
736            }),
737            pmtu: Some(PmtuFrame { pmtu: 1280 }),
738            bw_probe: vec![
739                BwProbeFrame {
740                    probe_id: 7,
741                    idx: 0,
742                    send_ts: 999,
743                },
744                BwProbeFrame {
745                    probe_id: 7,
746                    idx: 1,
747                    send_ts: 1099,
748                },
749            ],
750            trace: vec![TraceFrame {
751                hop_ttl: 5,
752                probe_id: 7,
753            }],
754            avail_bw: Some(AvailBwFrame {
755                avail_kbps: 45_000,
756                capacity_kbps: 100_000,
757            }),
758            forecast: Some(ForecastFrame {
759                forecast_kbps: 38_500,
760            }),
761            periodicity: Some(PeriodicityFrame {
762                period_ds: 150,
763                secs_to_spike_ds: 42,
764                confidence_x255: 200,
765            }),
766            session_challenge: Some(SessionFrame {
767                epoch: 0xDEAD_BEEF,
768                nonce: 0x0123_4567_89AB_CDEF,
769            }),
770            session_response: Some(SessionFrame {
771                epoch: 0xFEED_FACE,
772                // Masked, like a real generator: a wider value would come
773                // back clamped and no answer would compare equal.
774                nonce: 0xFEDC_BA98_7654_3210 & NONCE_MASK,
775            }),
776            session_announce: Some(0x1234_5678),
777        };
778        let wire = encode_control(&p);
779        assert_eq!(wire[0], PKT_CONTROL);
780        let got = decode_control(&wire).expect("decode");
781        assert_eq!(got, p, "full packet round-trips");
782    }
783
784    #[test]
785    fn padding_reaches_exact_size_and_still_decodes() {
786        let mut p = ControlPacket::new();
787        p.bw_probe.push(BwProbeFrame {
788            probe_id: 3,
789            idx: 1,
790            send_ts: 42,
791        });
792        let mut wire = encode_control(&p);
793        pad_control_to(&mut wire, 1400);
794        assert_eq!(wire.len(), 1400, "padded to the exact target size");
795        let got = decode_control(&wire).expect("decode");
796        assert_eq!(got.bw_probe, p.bw_probe, "the probe survives the padding");
797        assert!(got.avail_bw.is_none(), "the pad frame is skipped, not misread");
798    }
799
800    #[test]
801    fn empty_packet_is_just_the_tag() {
802        let p = ControlPacket::new();
803        assert!(p.is_empty());
804        let wire = encode_control(&p);
805        assert_eq!(wire, vec![PKT_CONTROL]);
806        assert_eq!(decode_control(&wire).unwrap(), p);
807    }
808
809    #[test]
810    fn unknown_frame_is_skipped_not_fatal() {
811        // Hand-build: a real ACK, then an unknown frame type 0x7F with a
812        // 4-byte body, then a real LINK. The unknown one must be skipped and
813        // both known frames decoded.
814        let mut wire = vec![PKT_CONTROL];
815        wire.push(FrameType::Ack as u8);
816        put_varint(&mut wire, 1);
817        wire.push(9); // ack_through = 9 (fits 6-bit varint)
818        wire.push(0x7F); // unknown frame type
819        put_varint(&mut wire, 4);
820        wire.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
821        wire.push(FrameType::Link as u8);
822        put_varint(&mut wire, 2);
823        wire.extend_from_slice(&[link_class::CELLULAR, 99]);
824
825        let p = decode_control(&wire).expect("decode");
826        assert_eq!(p.ack, Some(AckFrame { ack_through: 9 }));
827        assert_eq!(
828            p.link,
829            Some(LinkFrame {
830                class: link_class::CELLULAR,
831                quality: 99
832            })
833        );
834    }
835
836    #[test]
837    fn truncated_frame_length_aborts_cleanly() {
838        // A frame that claims 10 bytes but the buffer ends early: the parse
839        // keeps what came before and does not panic.
840        let mut wire = vec![PKT_CONTROL];
841        wire.push(FrameType::Ack as u8);
842        put_varint(&mut wire, 1);
843        wire.push(5);
844        wire.push(FrameType::Pmtu as u8);
845        put_varint(&mut wire, 10); // lies: only a couple bytes follow
846        wire.extend_from_slice(&[0x01, 0x02]);
847        let p = decode_control(&wire).expect("decode");
848        assert_eq!(p.ack, Some(AckFrame { ack_through: 5 }));
849        assert_eq!(p.pmtu, None, "truncated frame dropped");
850    }
851
852    #[test]
853    fn non_control_datagram_returns_none() {
854        assert!(decode_control(&[1, 2, 3]).is_none());
855        assert!(decode_control(&[]).is_none());
856        assert!(!is_control(&[2]));
857        assert!(is_control(&[PKT_CONTROL]));
858    }
859}