Skip to main content

ados_protocol/aux_rpc/
response.rs

1//! Response half of the relay-proxy RPC codec.
2//!
3//! A request fits one aux datagram; a response rarely does, so it is spread
4//! across fragments by [`split_response`] and rebuilt on the ground by
5//! [`ResponseDecoder`]. Two properties the request half does not need drive
6//! everything here.
7//!
8//! ## Fragments are RaptorQ symbols, not slices
9//!
10//! Per-fragment downlink loss measures 0.7%. All-or-nothing reassembly of a
11//! 26-fragment body therefore succeeds `(1 - 0.007)^26 = 83%`, which is the
12//! 17/20 measured against a live drone on `/api/status/full`. The failure is
13//! not a transport bug; it is geometric decay in the fragment count, and no
14//! amount of retransmitting a whole 30 KB response fixes it cheaply.
15//!
16//! So a body is encoded as one RaptorQ source block: `k` systematic symbols
17//! plus [`RPC_REPAIR_SYMBOLS`] repair symbols, of which **any `k`** rebuild the
18//! body. At `k = 26` that is 15% overhead — less than the aux lane's existing
19//! 2x FEC duplication — and drops the same failure below `1e-5`.
20//!
21//! ## Every fragment names its sender
22//!
23//! One fleet key covers up to 24 drones, so a broadcast request id can be
24//! answered by more than one aircraft at once. Splicing two senders' symbols
25//! into one decoder yields a body belonging to neither, and it decodes
26//! *silently* — RaptorQ has no per-object checksum to catch it. The sender's
27//! device id therefore leads every fragment, and the ground drops a fragment
28//! whose sender is not the drone it addressed.
29
30use raptorq::{Decoder, Encoder, EncodingPacket, ObjectTransmissionInformation, PayloadId};
31
32use super::RpcCodecError;
33use crate::aux_mux::AUX_MAX_PAYLOAD;
34use crate::node_status::MAX_DEVICE_ID;
35
36/// Fixed response-fragment overhead around the sender id and symbol bytes:
37/// 1 sender_len, 4 id, 2 status, 2 frag_index, 2 frag_total, 4 oti, 2 frag_len.
38pub const RPC_RESPONSE_OVERHEAD_BASE: usize = 17;
39
40/// Set on the wire `status` when the reassembled object carries a trailing
41/// header block.
42///
43/// # Why a flag and a trailer rather than per-fragment fields
44///
45/// Headers are per-RESPONSE, not per-fragment. Putting them beside `status` in
46/// the fragment header would replicate them across every fragment (26 of them
47/// on `/api/status/full`) and shrink [`MAX_RESPONSE_FRAGMENT`], which is the
48/// symbol size the whole RaptorQ geometry is derived from. So they ride inside
49/// the encoded object instead, after the body, and the fragment layout does not
50/// move at all.
51///
52/// HTTP statuses are 100..=599, so the top bit of the `u16` is free and
53/// unambiguous. A receiver that sees it knows the object has a trailer; one
54/// that does not see it treats the object as a bare body, which is exactly what
55/// a build without header support sends.
56///
57/// # Mixed builds
58///
59/// A drone older than this field sends no flag and no trailer, and a current
60/// ground reads its answer unchanged — that direction is fully compatible. The
61/// other direction is why [`pack_response`] emits a trailer ONLY when there is
62/// a header worth carrying: a JSON read, which is every relayed call that works
63/// today, stays byte-identical, and the responses that gain a trailer are the
64/// ones a headerless lane could not deliver usably in the first place.
65pub const RESPONSE_HEADERS_FLAG: u16 = 0x8000;
66
67/// Largest header block [`pack_response`] will attach. Headers are metadata
68/// about a body that is itself bounded by [`MAX_RESPONSE_BODY`]; a kilobyte is
69/// four times the allow-listed set's realistic worst case and keeps a
70/// misbehaving upstream from spending the body's airtime on its own headers.
71pub const MAX_RESPONSE_HEADER_BLOCK: usize = 1024;
72
73/// Repair symbols emitted alongside a response's systematic symbols.
74///
75/// Four is chosen against the measured 0.7% per-fragment loss and the 26-symbol
76/// worst case: it is enough to make loss of the whole response negligible while
77/// costing four frames on a body that already needs 26.
78pub const RPC_REPAIR_SYMBOLS: u32 = 4;
79
80/// Largest RaptorQ symbol one response fragment can carry.
81///
82/// The worst-case device id is budgeted whether or not the sender's id is that
83/// long, so the symbol geometry a receiver derives never varies with the
84/// sender's name. A receiver seeds its decoder from whichever fragment lands
85/// first and must not have to wait for a particular one to learn the shape.
86pub const MAX_RESPONSE_FRAGMENT: usize =
87    AUX_MAX_PAYLOAD - RPC_RESPONSE_OVERHEAD_BASE - MAX_DEVICE_ID;
88
89/// Most fragments one response may be split into.
90///
91/// Bounds a runaway reassembly buffer on the ground: a corrupt `frag_total` is
92/// a number the radio handed us, not one we chose.
93pub const MAX_RESPONSE_FRAGMENTS: usize = 64;
94
95/// Largest body [`split_response`] will encode.
96///
97/// The repair symbols are fragments too, so they come out of the same ceiling:
98/// 60 systematic symbols at [`MAX_RESPONSE_FRAGMENT`] each. The largest
99/// endpoint measured on a live drone (`/api/status/full`, 29 339 B) needs 26,
100/// so this is real headroom.
101pub const MAX_RESPONSE_BODY: usize =
102    (MAX_RESPONSE_FRAGMENTS - RPC_REPAIR_SYMBOLS as usize) * MAX_RESPONSE_FRAGMENT;
103
104/// A decoded response fragment. Borrows the original payload buffer.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct RpcResponse<'a> {
107    pub id: u32,
108    /// Device id of the drone that answered.
109    ///
110    /// The ground drops a fragment whose sender is not the drone it addressed,
111    /// so two aircraft answering one broadcast id cannot splice into one body.
112    pub sender: &'a [u8],
113    pub status: u16,
114    /// Encoding-symbol id of this fragment, which is also its 0-based position
115    /// in the transmitted sequence: `0..k` are systematic, the rest repair.
116    pub index: u16,
117    /// Fragments transmitted for this response, `k + RPC_REPAIR_SYMBOLS`.
118    /// Any `k` of them rebuild the body, so this is a ceiling, not a quorum.
119    pub total: u16,
120    /// The body length, and with it the whole RaptorQ geometry. Identical on
121    /// every fragment of one response, so a receiver seeds its decoder from
122    /// whichever arrives first.
123    pub oti: u32,
124    /// This fragment's symbol, not the whole body.
125    pub body: &'a [u8],
126}
127
128/// A response body encoded as the symbols that will carry it.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ResponseSymbols {
131    /// The RaptorQ object-transmission information, as the 4 bytes every
132    /// fragment carries.
133    ///
134    /// RFC 6330's OTI is 12 bytes, but eight of them are a build-time constant
135    /// both ends share (one source block, one sub-block, byte alignment, and a
136    /// symbol size derived from the body length), so the transfer length is the
137    /// whole variable part and `u32` covers [`MAX_RESPONSE_BODY`] many times
138    /// over.
139    pub oti: u32,
140    /// `k` systematic symbols followed by [`RPC_REPAIR_SYMBOLS`] repair
141    /// symbols, in encoding-symbol-id order.
142    pub symbols: Vec<Vec<u8>>,
143}
144
145/// The (systematic symbol count, symbol size) a body of `len` bytes uses.
146///
147/// Derived from the length alone, so the ground reconstructs it from the 4-byte
148/// `oti` with no second wire field. Symbols are sized to spread the body evenly
149/// across the fewest frames it needs rather than pinned at
150/// [`MAX_RESPONSE_FRAGMENT`]: RaptorQ symbols are all one size, so a pinned
151/// size would zero-pad a 200-byte answer into five full 1151-byte frames — 27x
152/// the airtime, on the one lane a 24-drone fleet is airtime-bound by.
153///
154/// `len` must be non-zero; an empty body carries no symbols at all.
155fn response_shape(len: usize) -> (usize, usize) {
156    let k = len.div_ceil(MAX_RESPONSE_FRAGMENT);
157    (k, len.div_ceil(k))
158}
159
160/// The RaptorQ config for a body of `len` bytes.
161///
162/// One source block, one sub-block, byte alignment: with a single block the
163/// encoding-symbol id *is* the fragment index, so no per-packet RaptorQ payload
164/// id has to ride the wire beside the index we already send.
165fn response_config(len: u32, symbol_size: usize) -> ObjectTransmissionInformation {
166    ObjectTransmissionInformation::new(len as u64, symbol_size as u16, 1, 1, 1)
167}
168
169/// Encode a response body as the symbols that will carry it.
170///
171/// An empty body yields exactly one empty symbol, so a 204 still travels as
172/// `total = 1` and the ground completes the call instead of waiting for a
173/// fragment that never comes. Returns `None` past [`MAX_RESPONSE_BODY`] — the
174/// caller emits a 413 rather than a body the ground cannot bound.
175pub fn split_response(body: &[u8]) -> Option<ResponseSymbols> {
176    if body.is_empty() {
177        // Nothing to protect and no block to decode. `oti = 0` is the empty
178        // body, and the ground completes on the first fragment it sees.
179        return Some(ResponseSymbols {
180            oti: 0,
181            symbols: vec![Vec::new()],
182        });
183    }
184    if body.len() > MAX_RESPONSE_BODY {
185        return None;
186    }
187    let (_, symbol_size) = response_shape(body.len());
188    let encoder = Encoder::new(body, response_config(body.len() as u32, symbol_size));
189    // Read the transfer length back off the encoder rather than assuming it:
190    // the OTI on the wire is the one the symbols were actually built from.
191    let oti = encoder.get_config().transfer_length() as u32;
192    Some(ResponseSymbols {
193        oti,
194        symbols: encoder
195            .get_encoded_packets(RPC_REPAIR_SYMBOLS)
196            .into_iter()
197            .map(|packet| packet.split().1)
198            .collect(),
199    })
200}
201
202/// One response header carried across the lane.
203pub type ResponseHeader = (String, String);
204
205/// Fold a body and its headers into the object [`split_response`] encodes, and
206/// return the wire `status` that describes it.
207///
208/// With no headers the object IS the body and the status is untouched, so a
209/// build with header support and one without put identical bytes on the air for
210/// the calls that carry none.
211///
212/// Layout of the trailer, appended after the body:
213///
214/// ```text
215///   per entry:  name_len (u8) | name | value_len (u16 BE) | value
216///   then:       block_len (u32 BE) — bytes of entries, NOT counting these 4
217/// ```
218///
219/// The length goes LAST because the reader arrives at the end of the object,
220/// not the start: it reads the final 4 bytes, steps back that far, and the
221/// bytes before that point are the body. A leading length would force the
222/// reader to know where the body ended in order to find where it ended.
223///
224/// An entry whose name or value will not fit its length prefix, or a block past
225/// [`MAX_RESPONSE_HEADER_BLOCK`], is dropped: a header is metadata, and losing
226/// one must never cost the body.
227pub fn pack_response(status: u16, body: &[u8], headers: &[ResponseHeader]) -> (u16, Vec<u8>) {
228    let mut block: Vec<u8> = Vec::new();
229    for (name, value) in headers {
230        if name.is_empty() || name.len() > u8::MAX as usize || value.len() > u16::MAX as usize {
231            continue;
232        }
233        let entry = 1 + name.len() + 2 + value.len();
234        if block.len() + entry > MAX_RESPONSE_HEADER_BLOCK {
235            break;
236        }
237        block.push(name.len() as u8);
238        block.extend_from_slice(name.as_bytes());
239        block.extend_from_slice(&(value.len() as u16).to_be_bytes());
240        block.extend_from_slice(value.as_bytes());
241    }
242    if block.is_empty() {
243        return (status, body.to_vec());
244    }
245    let mut object = Vec::with_capacity(body.len() + block.len() + 4);
246    object.extend_from_slice(body);
247    let block_len = block.len() as u32;
248    object.extend_from_slice(&block);
249    object.extend_from_slice(&block_len.to_be_bytes());
250    (status | RESPONSE_HEADERS_FLAG, object)
251}
252
253/// Split a reassembled object back into its real status, its body, and its
254/// headers. The inverse of [`pack_response`].
255///
256/// A corrupt or self-inconsistent trailer yields no headers and the object
257/// verbatim as the body: the body is the thing the caller asked for, and
258/// guessing at a damaged trailer could truncate it.
259pub fn unpack_response(status: u16, object: Vec<u8>) -> (u16, Vec<u8>, Vec<ResponseHeader>) {
260    if status & RESPONSE_HEADERS_FLAG == 0 {
261        return (status, object, Vec::new());
262    }
263    let status = status & !RESPONSE_HEADERS_FLAG;
264    let Some(len_at) = object.len().checked_sub(4) else {
265        return (status, object, Vec::new());
266    };
267    let block_len = u32::from_be_bytes([
268        object[len_at],
269        object[len_at + 1],
270        object[len_at + 2],
271        object[len_at + 3],
272    ]) as usize;
273    let Some(block_at) = len_at.checked_sub(block_len) else {
274        return (status, object, Vec::new());
275    };
276    let Some(headers) = parse_header_block(&object[block_at..len_at]) else {
277        return (status, object, Vec::new());
278    };
279    let mut body = object;
280    body.truncate(block_at);
281    (status, body, headers)
282}
283
284/// Parse the entry list. `None` on any inconsistency, so the caller keeps the
285/// object whole rather than trusting a half-read trailer.
286fn parse_header_block(mut block: &[u8]) -> Option<Vec<ResponseHeader>> {
287    let mut out = Vec::new();
288    while !block.is_empty() {
289        let name_len = *block.first()? as usize;
290        if name_len == 0 {
291            return None;
292        }
293        let rest = block.get(1..)?;
294        let name = std::str::from_utf8(rest.get(..name_len)?).ok()?;
295        let rest = rest.get(name_len..)?;
296        let value_len = u16::from_be_bytes([*rest.first()?, *rest.get(1)?]) as usize;
297        let rest = rest.get(2..)?;
298        let value = std::str::from_utf8(rest.get(..value_len)?).ok()?;
299        out.push((name.to_string(), value.to_string()));
300        block = rest.get(value_len..)?;
301    }
302    Some(out)
303}
304
305/// Encode one response fragment as an aux payload.
306///
307/// Returns `None` when the sender id exceeds [`MAX_DEVICE_ID`], the fragment
308/// does not fit one aux datagram, `total` is zero, or `index` is not inside
309/// `total`.
310pub fn encode_response_fragment(
311    sender: &[u8],
312    id: u32,
313    status: u16,
314    index: u16,
315    total: u16,
316    oti: u32,
317    symbol: &[u8],
318) -> Option<Vec<u8>> {
319    if sender.len() > MAX_DEVICE_ID {
320        return None;
321    }
322    if total == 0 || index >= total {
323        return None;
324    }
325    if symbol.len() > u16::MAX as usize {
326        return None;
327    }
328    let encoded_len = RPC_RESPONSE_OVERHEAD_BASE + sender.len() + symbol.len();
329    if encoded_len > AUX_MAX_PAYLOAD {
330        return None;
331    }
332    let mut out = Vec::with_capacity(encoded_len);
333    out.push(sender.len() as u8);
334    out.extend_from_slice(sender);
335    out.extend_from_slice(&id.to_be_bytes());
336    out.extend_from_slice(&status.to_be_bytes());
337    out.extend_from_slice(&index.to_be_bytes());
338    out.extend_from_slice(&total.to_be_bytes());
339    out.extend_from_slice(&oti.to_be_bytes());
340    out.extend_from_slice(&(symbol.len() as u16).to_be_bytes());
341    out.extend_from_slice(symbol);
342    Some(out)
343}
344
345/// Decode an aux payload as a response fragment.
346pub fn decode_response(payload: &[u8]) -> Result<RpcResponse<'_>, RpcCodecError> {
347    let &sender_len = payload.first().ok_or(RpcCodecError::TooShort)?;
348    if sender_len as usize > MAX_DEVICE_ID {
349        return Err(RpcCodecError::BadSenderLen(sender_len));
350    }
351    let sender_len = sender_len as usize;
352    let header = RPC_RESPONSE_OVERHEAD_BASE + sender_len;
353    if payload.len() < header {
354        return Err(RpcCodecError::TooShort);
355    }
356    let sender = &payload[1..1 + sender_len];
357    // The 16 fixed bytes between the sender id and the symbol.
358    let fixed = &payload[1 + sender_len..];
359    let id = u32::from_be_bytes([fixed[0], fixed[1], fixed[2], fixed[3]]);
360    let status = u16::from_be_bytes([fixed[4], fixed[5]]);
361    let index = u16::from_be_bytes([fixed[6], fixed[7]]);
362    let total = u16::from_be_bytes([fixed[8], fixed[9]]);
363    let oti = u32::from_be_bytes([fixed[10], fixed[11], fixed[12], fixed[13]]);
364    let frag_len = u16::from_be_bytes([fixed[14], fixed[15]]) as usize;
365    if header + frag_len != payload.len() {
366        return Err(RpcCodecError::LengthMismatch {
367            declared: header + frag_len,
368            actual: payload.len(),
369        });
370    }
371    if total == 0 || index >= total {
372        return Err(RpcCodecError::BadFragmentIndex { index, total });
373    }
374    Ok(RpcResponse {
375        id,
376        sender,
377        status,
378        index,
379        total,
380        oti,
381        body: &payload[header..],
382    })
383}
384
385/// What one fragment did to a [`ResponseDecoder`].
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub enum FragmentOutcome {
388    /// Accepted; more symbols are needed before the body rebuilds.
389    Pending,
390    /// Accepted, and it was the symbol that closed the block.
391    Complete(Vec<u8>),
392    /// Not a symbol this response could contain. Radio damage or a peer on a
393    /// different build. Dropped rather than fed to RaptorQ, which assumes every
394    /// symbol is one size and asserts on an out-of-range encoding-symbol id.
395    BadSymbol,
396}
397
398/// Ground-side reassembler for one response.
399///
400/// Seeded from any single fragment's `oti`, then fed fragments in whatever
401/// order the lane delivers them. Completes the moment enough symbols have
402/// arrived, which is `k` of the `k + RPC_REPAIR_SYMBOLS` transmitted — waiting
403/// for every index is exactly the geometric decay the repair symbols exist to
404/// remove.
405#[derive(Debug)]
406pub struct ResponseDecoder {
407    /// `None` for an empty body, which has no block to decode.
408    inner: Option<Decoder>,
409    symbol_size: usize,
410    /// One past the largest encoding-symbol id this response can contain.
411    symbol_id_limit: u32,
412}
413
414impl ResponseDecoder {
415    /// A decoder for a response whose fragments carry `oti`.
416    ///
417    /// Returns `None` for a transfer length past [`MAX_RESPONSE_BODY`]. The
418    /// field arrives off the radio and RaptorQ sizes its matrices from it, so
419    /// an unbounded value is an allocation the ground refuses rather than
420    /// attempts.
421    pub fn new(oti: u32) -> Option<Self> {
422        let len = oti as usize;
423        if len > MAX_RESPONSE_BODY {
424            return None;
425        }
426        if len == 0 {
427            return Some(Self {
428                inner: None,
429                symbol_size: 0,
430                symbol_id_limit: RPC_REPAIR_SYMBOLS,
431            });
432        }
433        let (k, symbol_size) = response_shape(len);
434        Some(Self {
435            inner: Some(Decoder::new(response_config(oti, symbol_size))),
436            symbol_size,
437            symbol_id_limit: k as u32 + RPC_REPAIR_SYMBOLS,
438        })
439    }
440
441    /// The symbol size every fragment of this response carries.
442    pub fn symbol_size(&self) -> usize {
443        self.symbol_size
444    }
445
446    /// Feed one fragment.
447    pub fn push(&mut self, index: u16, symbol: &[u8]) -> FragmentOutcome {
448        if symbol.len() != self.symbol_size || index as u32 >= self.symbol_id_limit {
449            return FragmentOutcome::BadSymbol;
450        }
451        let Some(decoder) = self.inner.as_mut() else {
452            return FragmentOutcome::Complete(Vec::new());
453        };
454        let packet = EncodingPacket::new(PayloadId::new(0, index as u32), symbol.to_vec());
455        match decoder.decode(packet) {
456            Some(body) => FragmentOutcome::Complete(body),
457            None => FragmentOutcome::Pending,
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    const SENDER: &[u8] = b"ados-abc123";
467
468    fn hdr(name: &str, value: &str) -> ResponseHeader {
469        (name.to_string(), value.to_string())
470    }
471
472    #[test]
473    fn a_headerless_response_is_byte_identical_to_a_build_without_header_support() {
474        // The compatibility guarantee: an upgraded drone answering a relayed
475        // JSON read puts exactly the bytes on the air that it always did, so a
476        // ground station that has not upgraded keeps working.
477        let body = br#"{"ok":true}"#;
478        let (status, object) = pack_response(200, body, &[]);
479        assert_eq!(status, 200, "no flag when there is nothing to carry");
480        assert_eq!(object, body);
481    }
482
483    #[test]
484    fn a_content_type_survives_the_round_trip() {
485        let body = b"id,lat,lon\n1,0,0\n";
486        let headers = vec![
487            hdr("content-type", "text/csv; charset=utf-8"),
488            hdr("content-disposition", "attachment; filename=\"log.csv\""),
489        ];
490        let (wire_status, object) = pack_response(200, body, &headers);
491        assert_eq!(wire_status & RESPONSE_HEADERS_FLAG, RESPONSE_HEADERS_FLAG);
492
493        let (status, got_body, got_headers) = unpack_response(wire_status, object);
494        assert_eq!(
495            status, 200,
496            "the flag is stripped, not leaked to the caller"
497        );
498        assert_eq!(got_body, body);
499        assert_eq!(got_headers, headers);
500    }
501
502    #[test]
503    fn headers_survive_the_whole_fragment_round_trip_with_a_lost_fragment() {
504        // The end-to-end property: pack → split → lose a symbol → reassemble →
505        // unpack still yields the body AND its content-type.
506        let body = vec![0xABu8; 4000];
507        let headers = vec![hdr("content-type", "application/octet-stream")];
508        let (wire_status, object) = pack_response(200, &body, &headers);
509        let split = split_response(&object).unwrap();
510        let total = split.symbols.len() as u16;
511        let mut decoder = ResponseDecoder::new(split.oti).unwrap();
512        let mut assembled = None;
513        for (i, symbol) in split.symbols.iter().enumerate() {
514            if i == 1 {
515                continue; // radio dropped it; the repair symbols cover it
516            }
517            let payload = encode_response_fragment(
518                SENDER,
519                9,
520                wire_status,
521                i as u16,
522                total,
523                split.oti,
524                symbol,
525            )
526            .unwrap();
527            let frag = decode_response(&payload).unwrap();
528            assert_eq!(frag.status, wire_status);
529            if let FragmentOutcome::Complete(out) = decoder.push(frag.index, frag.body) {
530                assembled = Some(out);
531                break;
532            }
533        }
534        let (status, got_body, got_headers) = unpack_response(
535            wire_status,
536            assembled.expect("repair symbols must close the block"),
537        );
538        assert_eq!(status, 200);
539        assert_eq!(got_body, body);
540        assert_eq!(got_headers, headers);
541    }
542
543    #[test]
544    fn a_damaged_trailer_yields_the_object_verbatim_rather_than_a_truncated_body() {
545        // Never guess at a trailer: a wrong guess silently shortens the body,
546        // which is the one failure the caller cannot detect.
547        let (wire_status, mut object) = pack_response(200, b"payload", &[hdr("a", "b")]);
548        let last = object.len() - 1;
549        object[last] = 0xFF; // block_len now points past the front of the object
550        let (status, body, headers) = unpack_response(wire_status, object.clone());
551        assert_eq!(status, 200);
552        assert_eq!(body, object);
553        assert!(headers.is_empty());
554    }
555
556    #[test]
557    fn a_header_block_past_the_ceiling_is_truncated_not_allowed_to_eat_the_body() {
558        let huge = "x".repeat(MAX_RESPONSE_HEADER_BLOCK);
559        let headers = vec![hdr("content-type", "text/plain"), hdr("bloat", &huge)];
560        let (wire_status, object) = pack_response(200, b"body", &headers);
561        let (_, body, got) = unpack_response(wire_status, object);
562        assert_eq!(body, b"body");
563        assert_eq!(got, vec![hdr("content-type", "text/plain")]);
564    }
565
566    /// Encode a whole body the way the drone does, then decode it the way the
567    /// ground does, delivering `order` and skipping `drop`.
568    fn round_trip(body: &[u8], order: &[usize], drop: &[usize]) -> Option<Vec<u8>> {
569        let split = split_response(body).unwrap();
570        let total = split.symbols.len() as u16;
571        let mut decoder: Option<ResponseDecoder> = None;
572        for &i in order {
573            if drop.contains(&i) {
574                continue;
575            }
576            let payload = encode_response_fragment(
577                SENDER,
578                7,
579                200,
580                i as u16,
581                total,
582                split.oti,
583                &split.symbols[i],
584            )
585            .unwrap();
586            let frag = decode_response(&payload).unwrap();
587            assert_eq!(frag.sender, SENDER);
588            assert_eq!(frag.oti, split.oti);
589            assert_eq!(frag.total, total);
590            let d = decoder.get_or_insert_with(|| ResponseDecoder::new(frag.oti).unwrap());
591            if let FragmentOutcome::Complete(out) = d.push(frag.index, frag.body) {
592                return Some(out);
593            }
594        }
595        None
596    }
597
598    #[test]
599    fn round_trips_a_single_fragment_response() {
600        let body = br#"{"device_id":"abc","name":"Skynode A7S"}"#;
601        let split = split_response(body).unwrap();
602        assert_eq!(split.oti, body.len() as u32);
603        let enc = encode_response_fragment(
604            SENDER,
605            42,
606            200,
607            0,
608            split.symbols.len() as u16,
609            split.oti,
610            &split.symbols[0],
611        )
612        .unwrap();
613        let dec = decode_response(&enc).unwrap();
614        assert_eq!(dec.id, 42);
615        assert_eq!(dec.sender, SENDER);
616        assert_eq!(dec.status, 200);
617        assert_eq!(dec.index, 0);
618        assert_eq!(round_trip(body, &[0], &[]).as_deref(), Some(&body[..]));
619    }
620
621    #[test]
622    fn an_empty_body_still_produces_one_fragment() {
623        // A 204 must complete the caller, not leave it waiting for a fragment
624        // that never comes.
625        let split = split_response(&[]).unwrap();
626        assert_eq!(split.oti, 0);
627        assert_eq!(split.symbols.len(), 1);
628        assert!(split.symbols[0].is_empty());
629        let enc = encode_response_fragment(SENDER, 7, 204, 0, 1, 0, &split.symbols[0]).unwrap();
630        let dec = decode_response(&enc).unwrap();
631        assert_eq!(dec.status, 204);
632        assert_eq!(dec.total, 1);
633        assert!(dec.body.is_empty());
634        let mut decoder = ResponseDecoder::new(dec.oti).unwrap();
635        assert_eq!(
636            decoder.push(dec.index, dec.body),
637            FragmentOutcome::Complete(Vec::new())
638        );
639    }
640
641    #[test]
642    fn a_response_carries_k_systematic_plus_the_repair_symbols() {
643        let body: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
644        let split = split_response(&body).unwrap();
645        let k = body.len().div_ceil(MAX_RESPONSE_FRAGMENT);
646        assert_eq!(k, 3);
647        assert_eq!(split.symbols.len(), k + RPC_REPAIR_SYMBOLS as usize);
648        // Every symbol is one size — RaptorQ requires it, and the ground
649        // rejects any fragment that is not.
650        let size = split.symbols[0].len();
651        assert!(split.symbols.iter().all(|s| s.len() == size));
652        assert!(size <= MAX_RESPONSE_FRAGMENT);
653    }
654
655    #[test]
656    fn a_thirty_kilobyte_body_survives_losing_any_four_fragments() {
657        // The measured `/api/status/full`. All-or-nothing reassembly of its 26
658        // fragments at 0.7% per-fragment loss is the 85% success measured on the
659        // bench; the repair symbols are what remove that decay.
660        let body: Vec<u8> = (0..29_339u32).map(|i| (i % 251) as u8).collect();
661        let split = split_response(&body).unwrap();
662        assert_eq!(split.symbols.len(), 30, "26 systematic + 4 repair");
663        let order: Vec<usize> = (0..30).collect();
664        for drop in [
665            vec![0usize, 1, 2, 3],
666            vec![26usize, 27, 28, 29],
667            vec![0usize, 9, 17, 29],
668            vec![5usize, 6, 7, 25],
669        ] {
670            assert_eq!(
671                round_trip(&body, &order, &drop).as_deref(),
672                Some(&body[..]),
673                "dropping {drop:?}"
674            );
675        }
676    }
677
678    #[test]
679    fn fragments_rejoin_out_of_order() {
680        let body: Vec<u8> = (0..8000u32).map(|i| (i % 251) as u8).collect();
681        let split = split_response(&body).unwrap();
682        let mut order: Vec<usize> = (0..split.symbols.len()).collect();
683        order.reverse();
684        assert_eq!(round_trip(&body, &order, &[]).as_deref(), Some(&body[..]));
685    }
686
687    #[test]
688    fn a_body_past_the_reassembly_ceiling_does_not_split() {
689        assert!(split_response(&vec![0u8; MAX_RESPONSE_BODY + 1]).is_none());
690        let at_ceiling = vec![0u8; MAX_RESPONSE_BODY];
691        assert_eq!(
692            split_response(&at_ceiling).unwrap().symbols.len(),
693            MAX_RESPONSE_FRAGMENTS
694        );
695    }
696
697    #[test]
698    fn rejects_a_fragment_larger_than_one_aux_frame() {
699        let big = vec![0u8; MAX_RESPONSE_FRAGMENT + 1];
700        let sender = vec![b'a'; MAX_DEVICE_ID];
701        assert!(encode_response_fragment(&sender, 1, 200, 0, 1, 1, &big).is_none());
702        let fits = vec![0u8; MAX_RESPONSE_FRAGMENT];
703        let enc = encode_response_fragment(&sender, 1, 200, 0, 1, 1, &fits).unwrap();
704        assert_eq!(enc.len(), AUX_MAX_PAYLOAD, "the budget is exact");
705        assert_eq!(
706            decode_response(&enc).unwrap().body.len(),
707            MAX_RESPONSE_FRAGMENT
708        );
709    }
710
711    #[test]
712    fn rejects_a_sender_longer_than_a_device_id() {
713        let too_long = vec![b'a'; MAX_DEVICE_ID + 1];
714        assert!(encode_response_fragment(&too_long, 1, 200, 0, 1, 1, b"x").is_none());
715        // A hand-built frame claiming an oversized sender must not decode: the
716        // length prefix indexes the buffer, so an unchecked one reads a symbol
717        // out of the sender's bytes.
718        let mut bad = vec![(MAX_DEVICE_ID + 1) as u8];
719        bad.extend_from_slice(&too_long);
720        bad.extend_from_slice(&[0u8; 16]);
721        assert_eq!(
722            decode_response(&bad).unwrap_err(),
723            RpcCodecError::BadSenderLen((MAX_DEVICE_ID + 1) as u8)
724        );
725    }
726
727    #[test]
728    fn rejects_a_fragment_whose_index_is_outside_its_total() {
729        assert!(encode_response_fragment(SENDER, 1, 200, 3, 3, 1, b"x").is_none());
730        assert!(encode_response_fragment(SENDER, 1, 200, 0, 0, 1, b"x").is_none());
731        // Hand-built frame claiming index 4 of 2 must not decode.
732        let mut bad = vec![0u8];
733        bad.extend_from_slice(&1u32.to_be_bytes());
734        bad.extend_from_slice(&200u16.to_be_bytes());
735        bad.extend_from_slice(&4u16.to_be_bytes());
736        bad.extend_from_slice(&2u16.to_be_bytes());
737        bad.extend_from_slice(&1u32.to_be_bytes());
738        bad.extend_from_slice(&1u16.to_be_bytes());
739        bad.push(b'x');
740        assert_eq!(
741            decode_response(&bad).unwrap_err(),
742            RpcCodecError::BadFragmentIndex { index: 4, total: 2 }
743        );
744    }
745
746    #[test]
747    fn rejects_a_truncated_response() {
748        let full = encode_response_fragment(SENDER, 9, 200, 0, 1, 4, b"body").unwrap();
749        for cut in [0usize, 1, 4, RPC_RESPONSE_OVERHEAD_BASE + SENDER.len() - 1] {
750            let mut truncated = full.clone();
751            truncated.truncate(cut);
752            assert_eq!(
753                decode_response(&truncated).unwrap_err(),
754                RpcCodecError::TooShort,
755                "cut to {cut}"
756            );
757        }
758    }
759
760    #[test]
761    fn catches_a_length_mismatch_in_a_response() {
762        // Claimed frag_len = 50 but the payload ends after the header.
763        let mut bad = vec![0u8];
764        bad.extend_from_slice(&1u32.to_be_bytes());
765        bad.extend_from_slice(&200u16.to_be_bytes());
766        bad.extend_from_slice(&0u16.to_be_bytes());
767        bad.extend_from_slice(&1u16.to_be_bytes());
768        bad.extend_from_slice(&50u32.to_be_bytes());
769        bad.extend_from_slice(&50u16.to_be_bytes());
770        assert_eq!(
771            decode_response(&bad).unwrap_err(),
772            RpcCodecError::LengthMismatch {
773                declared: RPC_RESPONSE_OVERHEAD_BASE + 50,
774                actual: RPC_RESPONSE_OVERHEAD_BASE
775            }
776        );
777    }
778
779    #[test]
780    fn an_impossible_index_total_pair_is_its_own_fault_not_a_length_fault() {
781        // index 3 of total 2. Reported as a length mismatch, the numbers read
782        // as byte counts and the real fault is invisible in the log line.
783        let mut bad = vec![0u8];
784        bad.extend_from_slice(&1u32.to_be_bytes());
785        bad.extend_from_slice(&200u16.to_be_bytes());
786        bad.extend_from_slice(&3u16.to_be_bytes());
787        bad.extend_from_slice(&2u16.to_be_bytes());
788        bad.extend_from_slice(&0u32.to_be_bytes());
789        bad.extend_from_slice(&0u16.to_be_bytes());
790        assert_eq!(
791            decode_response(&bad).unwrap_err(),
792            RpcCodecError::BadFragmentIndex { index: 3, total: 2 }
793        );
794    }
795
796    #[test]
797    fn a_decoder_refuses_an_unbounded_transfer_length() {
798        // The field arrives off the radio. RaptorQ sizes its matrices from it,
799        // so an unchecked value is an allocation a corrupt frame can demand.
800        assert!(ResponseDecoder::new(u32::MAX).is_none());
801        assert!(ResponseDecoder::new(MAX_RESPONSE_BODY as u32 + 1).is_none());
802        assert!(ResponseDecoder::new(MAX_RESPONSE_BODY as u32).is_some());
803    }
804
805    #[test]
806    fn a_decoder_drops_a_symbol_of_the_wrong_size_or_id() {
807        let body: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
808        let split = split_response(&body).unwrap();
809        let mut decoder = ResponseDecoder::new(split.oti).unwrap();
810        let size = decoder.symbol_size();
811        assert_eq!(size, split.symbols[0].len());
812        // A short symbol would be fed into a matrix that assumes one size.
813        assert_eq!(
814            decoder.push(0, &vec![0u8; size - 1]),
815            FragmentOutcome::BadSymbol
816        );
817        // An encoding-symbol id past what this response can contain: `total`
818        // comes off the radio, and RaptorQ asserts on a wild id.
819        let k = body.len().div_ceil(MAX_RESPONSE_FRAGMENT);
820        assert_eq!(
821            decoder.push((k + RPC_REPAIR_SYMBOLS as usize) as u16, &vec![0u8; size]),
822            FragmentOutcome::BadSymbol
823        );
824        // Rejecting them left the decoder usable.
825        for (i, symbol) in split.symbols.iter().enumerate() {
826            if let FragmentOutcome::Complete(out) = decoder.push(i as u16, symbol) {
827                assert_eq!(out, body);
828                return;
829            }
830        }
831        panic!("the full symbol set must decode");
832    }
833
834    #[test]
835    fn every_body_size_across_the_fragment_range_round_trips() {
836        // The k boundaries are where the derived symbol size changes, and a
837        // shape the two ends disagree on decodes to silent garbage.
838        for k in [1usize, 2, 26, 60] {
839            for len in [
840                (k - 1) * MAX_RESPONSE_FRAGMENT + 1,
841                k * MAX_RESPONSE_FRAGMENT,
842            ] {
843                let body: Vec<u8> = (0..len).map(|i| ((i * 7 + 13) % 251) as u8).collect();
844                let split = split_response(&body).unwrap();
845                assert_eq!(
846                    split.symbols.len(),
847                    k + RPC_REPAIR_SYMBOLS as usize,
848                    "len {len}"
849                );
850                let order: Vec<usize> = (0..split.symbols.len()).collect();
851                assert_eq!(
852                    round_trip(&body, &order, &[0, 1]).as_deref(),
853                    Some(&body[..]),
854                    "len {len}"
855                );
856            }
857        }
858    }
859
860    #[test]
861    fn frame_budgets_account_for_fixed_overhead() {
862        // A response fragment's fixed overhead is 17 bytes: 1 sender_len,
863        // 4 id, 2 status, 2 frag_index, 2 frag_total, 4 oti, 2 frag_len. The
864        // worst-case 32-byte device id is budgeted on every fragment whether or
865        // not this sender's id is that long, so the symbol size both ends derive
866        // from `oti` never varies with the sender's name.
867        assert_eq!(RPC_RESPONSE_OVERHEAD_BASE, 17);
868        assert_eq!(MAX_RESPONSE_FRAGMENT, 1200 - 17 - 32);
869        assert_eq!(MAX_RESPONSE_FRAGMENT, 1151);
870        // The repair symbols come out of the fragment ceiling too.
871        assert_eq!(MAX_RESPONSE_BODY, 60 * 1151);
872        // The ceiling must cover the largest endpoint measured on a live drone
873        // (/api/status/full at 29 339 B), which needs 26 systematic symbols.
874        const { assert!(MAX_RESPONSE_BODY > 29_339) };
875        assert_eq!(29_339usize.div_ceil(MAX_RESPONSE_FRAGMENT), 26);
876    }
877}