Skip to main content

ados_protocol/
aux_rpc.rs

1//! Bidirectional RPC frames over the auxiliary lane.
2//!
3//! [`aux_mux`](crate::aux_mux) says what an aux datagram means; this module is
4//! what a relay-proxy request and response look like on the application lane.
5//! The ground station, paired to a drone only through WFB, has no IP reach to
6//! the linked drone; `AuxEgress` carries low-rate framing onto the radio
7//! uplink, and the drone's `aux_uplink_consumer` reads it. Here we define two
8//! payload shapes that ride that pair, so a ground station's `relay-proxy`
9//! HTTP route can forward `GET /api/pairing/info` (and the rest of the
10//! agent's own HTTP surface) to a drone it cannot address directly.
11//!
12//! ## Why not reuse an existing envelope
13//!
14//! The MAVLink TUNNEL extension (the `ados-mavlink-tunnel` crate) chunks a single body
15//! across 128-byte frames, sized for the radio's control plane. The aux lane
16//! carries a normal UDP-sized datagram, so a request and a response each span
17//! as few frames as their bytes need. The plugin msgpack envelope is heavier
18//! than this lane should pay per frame at the rates a small set of HTTP calls
19//! warrant.
20//!
21//! ## Wire layout
22//!
23//! Both frames travel as the aux payload (after the 6-byte aux header). All
24//! multi-byte integers are big-endian, matching the aux header's length field
25//! and the rest of the agent's framed IPC.
26//!
27//! ```text
28//! RpcRequestFragment
29//!   byte 0              method (u8: 1=GET, 2=POST, 3=PUT, 4=DELETE, 5=PATCH)
30//!   byte 1..5           id (u32 BE) — correlates the response
31//!   byte 5              target_len (u8; 0 = broadcast, accepted by every drone)
32//!   byte 6..6+T         target device id (UTF-8, <= MAX_DEVICE_ID)
33//!   byte 6+T..8+T       seq (u16 BE, 0-based)
34//!   byte 8+T..10+T      total (u16 BE, >= 1)
35//!   byte 10+T..         chunk bytes (the rest of the payload)
36//!
37//! The chunks, concatenated in `seq` order, are the REQUEST OBJECT:
38//!   path_len (u16 BE) | path | content_type_len (u8) | content_type |
39//!   ticket_len (u16 BE) | ticket | body (every remaining byte)
40//!
41//! RpcResponseFragment
42//!   byte 0              sender_len (u8, <= MAX_DEVICE_ID)
43//!   byte 1..1+S         sender device id (UTF-8) — which drone answered
44//!   byte 1+S..5+S       id (u32 BE) — matches the request's
45//!   byte 5+S..7+S       status (u16 BE) — HTTP status, repeated identically on
46//!                       every fragment so a reassembler can seed from
47//!                       whichever arrives first. The top bit
48//!                       ([`response::RESPONSE_HEADERS_FLAG`]) is not part of
49//!                       the status: it marks that the ENCODED OBJECT (not the
50//!                       fragment) carries a trailing header block.
51//!   byte 7+S..9+S       frag_index (u16 BE, 0-based; also the RaptorQ
52//!                       encoding-symbol id)
53//!   byte 9+S..11+S      frag_total (u16 BE, >= 1)
54//!   byte 11+S..15+S     oti (u32 BE) — the RaptorQ transfer length, identical
55//!                       on every fragment of one response
56//!   byte 15+S..17+S     frag_len (u16 BE) — symbol bytes in THIS fragment
57//!   byte 17+S..         symbol bytes
58//! ```
59//!
60//! The bytes the fragments carry are not the body directly, they are an
61//! **encoded object**. With no headers the object IS the body. With headers it
62//! is `body | header entries | block_len (u32 BE)`, and the status's top bit
63//! says so — see [`response::pack_response`]. Headers therefore cost nothing
64//! per fragment and do not move [`response::MAX_RESPONSE_FRAGMENT`], which the
65//! whole symbol geometry is derived from.
66//!
67//! ## Both directions fragment
68//!
69//! A request is split across as many frames as its object needs, up to
70//! [`MAX_REQUEST_BODY`] of body. Plugin routes and larger writes (a mission, a
71//! uploaded file, a non-JSON plugin payload) do not fit one 1.2 KB frame, and
72//! a relay that refused them with a 413 left a radio-only drone without those
73//! surfaces at all. Request fragments are plain chunks rather than RaptorQ
74//! symbols: the ground retransmits a whole request it has no answer for, and
75//! the drone's [`RequestReassembler`] keeps what already arrived.
76//!
77//! Responses fragment too: measured against a live drone, `/api/services` is
78//! 2 631 B, `/api/config` 5 101 B, and `/api/status/full` 29 339 B, all past the
79//! [`response::MAX_RESPONSE_FRAGMENT`] budget. A response is therefore
80//! forward-error-corrected and chunked by [`split_response`]; see
81//! [`response`] for why a fragment is a RaptorQ symbol and why it names its
82//! sender.
83//!
84//! ## Version
85//!
86//! `aux_mux::AUX_VERSION` guards every aux channel. Bumping it for an RPC-only
87//! layout would make a mid-upgrade mixed pair drop MAVLink, Status, and
88//! Identity frames too, so the RPC layout changes without it: a mismatched
89//! pair degrades to a 504 on relay calls alone until both ends run the same
90//! build.
91//!
92//! ## Correlation
93//!
94//! The 32-bit request id is assigned by the ground side and travels both
95//! directions. A ground station running one proxy caller can use a monotonic
96//! counter; a 32-bit space at a few calls per second does not roll in any
97//! realistic session. Every response fragment carries the same id back
98//! unchanged, so a pending-request map on the ground can match a fragment to
99//! its caller without sequencing across radio reordering. The id alone is not
100//! enough to identify the answering drone once a fleet shares one key, which
101//! is what the fragment's `sender` field is for.
102
103pub mod response;
104
105pub use response::{
106    decode_response, encode_response_fragment, pack_response, split_response, unpack_response,
107    FragmentOutcome, ResponseDecoder, ResponseHeader, ResponseSymbols, RpcResponse,
108    MAX_RESPONSE_BODY, MAX_RESPONSE_FRAGMENT, MAX_RESPONSE_FRAGMENTS, MAX_RESPONSE_HEADER_BLOCK,
109    RESPONSE_HEADERS_FLAG, RPC_REPAIR_SYMBOLS, RPC_RESPONSE_OVERHEAD_BASE,
110};
111
112use crate::aux_mux::AUX_MAX_PAYLOAD;
113use crate::node_status::MAX_DEVICE_ID;
114
115/// Fixed request-fragment overhead with an empty target: 1 byte method, 4 id,
116/// 1 target_len, 2 seq, 2 total.
117pub const RPC_REQUEST_FRAGMENT_OVERHEAD: usize = 10;
118
119/// Chunk bytes one request fragment carries, sized so a worst-case device id
120/// still fits [`AUX_MAX_PAYLOAD`].
121pub const MAX_REQUEST_CHUNK: usize =
122    AUX_MAX_PAYLOAD - RPC_REQUEST_FRAGMENT_OVERHEAD - MAX_DEVICE_ID;
123
124/// Largest request body the relay carries. A larger one is refused with 413 on
125/// the ground before anything is sent.
126pub const MAX_REQUEST_BODY: usize = 1024 * 1024;
127
128/// Largest relay ticket a request object can carry.
129pub const MAX_REQUEST_TICKET: usize = 1024;
130
131/// Largest request object: the body plus its path, content type and ticket.
132pub const MAX_REQUEST_OBJECT: usize =
133    MAX_REQUEST_BODY + 2 + u16::MAX as usize + 1 + u8::MAX as usize + 2 + MAX_REQUEST_TICKET;
134
135/// Most fragments one request may span.
136pub const MAX_REQUEST_FRAGMENTS: usize = MAX_REQUEST_OBJECT.div_ceil(MAX_REQUEST_CHUNK);
137
138/// How long the drone keeps a partially received request before dropping it.
139/// Longer than the ground's call bound, so a retransmit can still fill the
140/// gaps of the attempt before it.
141pub const REQUEST_REASSEMBLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
142
143/// Most requests the drone reassembles at once.
144pub const MAX_PARTIAL_REQUESTS: usize = 8;
145
146/// Most bytes the drone buffers across every partial request at once.
147pub const MAX_PARTIAL_REQUEST_BYTES: usize = 4 * MAX_REQUEST_OBJECT;
148
149/// HTTP method tag, encoded as a single byte on the wire.
150///
151/// Values are explicit and MUST NOT be renumbered: a renumber would silently
152/// reroute a POST into a GET handler on the far side.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154#[repr(u8)]
155pub enum RpcMethod {
156    Get = 1,
157    Post = 2,
158    Put = 3,
159    Delete = 4,
160    Patch = 5,
161}
162
163impl RpcMethod {
164    /// Parse a method byte. Unknown values return `None` so a reader drops
165    /// the frame instead of guessing.
166    pub fn from_u8(v: u8) -> Option<Self> {
167        match v {
168            1 => Some(Self::Get),
169            2 => Some(Self::Post),
170            3 => Some(Self::Put),
171            4 => Some(Self::Delete),
172            5 => Some(Self::Patch),
173            _ => None,
174        }
175    }
176
177    /// Parse an HTTP method name. `None` for a method the relay does not carry.
178    pub fn from_http_method(method: &str) -> Option<Self> {
179        match method {
180            "GET" => Some(Self::Get),
181            "POST" => Some(Self::Post),
182            "PUT" => Some(Self::Put),
183            "DELETE" => Some(Self::Delete),
184            "PATCH" => Some(Self::Patch),
185            _ => None,
186        }
187    }
188
189    /// The HTTP method string, for a proxy that re-emits the request.
190    pub fn as_http_method(&self) -> &'static str {
191        match self {
192            Self::Get => "GET",
193            Self::Post => "POST",
194            Self::Put => "PUT",
195            Self::Delete => "DELETE",
196            Self::Patch => "PATCH",
197        }
198    }
199}
200
201/// A whole request, reassembled from its fragments.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct RpcRequest {
204    pub id: u32,
205    pub method: RpcMethod,
206    /// The device id this request is addressed to. Empty means broadcast, and
207    /// every drone on the pair answers it.
208    pub target: Vec<u8>,
209    /// Absolute path on the drone's API, still percent-encoded, with any query.
210    pub path: Vec<u8>,
211    /// The caller's `Content-Type`, or empty when it sent none.
212    pub content_type: Vec<u8>,
213    /// The relay ticket accompanying this call, or empty when none was sent
214    /// (only the secret delivery itself travels without one).
215    pub ticket: Vec<u8>,
216    pub body: Vec<u8>,
217}
218
219/// One fragment of a request, borrowed from the payload it was decoded from.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub struct RequestFragment<'a> {
222    pub id: u32,
223    pub method: RpcMethod,
224    pub target: &'a [u8],
225    pub seq: u16,
226    pub total: u16,
227    pub chunk: &'a [u8],
228}
229
230/// Why a frame could not be decoded. Distinct variants so a caller can count
231/// transport damage separately from foreign traffic on a shared lane.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum RpcCodecError {
234    /// Shorter than the fixed header.
235    TooShort,
236    /// A method byte this build does not know.
237    BadMethod(u8),
238    /// The declared lengths do not match the bytes actually present.
239    LengthMismatch { declared: usize, actual: usize },
240    /// The fragment's own index/total pair is impossible. Kept separate from
241    /// [`Self::LengthMismatch`], whose fields are byte counts.
242    BadFragmentIndex { index: u16, total: u16 },
243    /// A response fragment claims a sender id longer than [`MAX_DEVICE_ID`].
244    BadSenderLen(u8),
245}
246
247/// Why a request could not be encoded.
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum RequestEncodeError {
250    /// The body is larger than [`MAX_REQUEST_BODY`]; the caller answers 413.
251    BodyTooLarge,
252    /// The target, path, content type or ticket cannot be length-prefixed.
253    FieldTooLong,
254}
255
256/// Encode a request as the aux payloads (the bytes that go inside each aux
257/// frame AFTER the 6-byte aux header) that carry it, in send order.
258///
259/// `target` is the device id the request is addressed to; an empty slice is a
260/// broadcast every linked drone answers.
261pub fn encode_request(
262    method: RpcMethod,
263    id: u32,
264    target: &[u8],
265    request: &RequestParts<'_>,
266) -> Result<Vec<Vec<u8>>, RequestEncodeError> {
267    if request.body.len() > MAX_REQUEST_BODY {
268        return Err(RequestEncodeError::BodyTooLarge);
269    }
270    if target.len() > MAX_DEVICE_ID
271        || request.path.len() > u16::MAX as usize
272        || request.content_type.len() > u8::MAX as usize
273        || request.ticket.len() > MAX_REQUEST_TICKET
274    {
275        return Err(RequestEncodeError::FieldTooLong);
276    }
277    let mut object = Vec::with_capacity(
278        5 + request.path.len()
279            + request.content_type.len()
280            + request.ticket.len()
281            + request.body.len(),
282    );
283    object.extend_from_slice(&(request.path.len() as u16).to_be_bytes());
284    object.extend_from_slice(request.path);
285    object.push(request.content_type.len() as u8);
286    object.extend_from_slice(request.content_type);
287    object.extend_from_slice(&(request.ticket.len() as u16).to_be_bytes());
288    object.extend_from_slice(request.ticket);
289    object.extend_from_slice(request.body);
290
291    let chunks: Vec<&[u8]> = object.chunks(MAX_REQUEST_CHUNK).collect();
292    let total = chunks.len() as u16;
293    Ok(chunks
294        .iter()
295        .enumerate()
296        .map(|(seq, chunk)| {
297            let mut out =
298                Vec::with_capacity(RPC_REQUEST_FRAGMENT_OVERHEAD + target.len() + chunk.len());
299            out.push(method as u8);
300            out.extend_from_slice(&id.to_be_bytes());
301            out.push(target.len() as u8);
302            out.extend_from_slice(target);
303            out.extend_from_slice(&(seq as u16).to_be_bytes());
304            out.extend_from_slice(&total.to_be_bytes());
305            out.extend_from_slice(chunk);
306            out
307        })
308        .collect())
309}
310
311/// The parts of a request that travel inside its object.
312#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
313pub struct RequestParts<'a> {
314    pub path: &'a [u8],
315    pub content_type: &'a [u8],
316    pub ticket: &'a [u8],
317    pub body: &'a [u8],
318}
319
320/// Decode one request fragment.
321pub fn decode_request_fragment(payload: &[u8]) -> Result<RequestFragment<'_>, RpcCodecError> {
322    if payload.len() < RPC_REQUEST_FRAGMENT_OVERHEAD {
323        return Err(RpcCodecError::TooShort);
324    }
325    let method = RpcMethod::from_u8(payload[0]).ok_or(RpcCodecError::BadMethod(payload[0]))?;
326    let id = u32::from_be_bytes([payload[1], payload[2], payload[3], payload[4]]);
327    let target_len = payload[5] as usize;
328    let seq_offset = 6 + target_len;
329    if seq_offset + 4 > payload.len() {
330        return Err(RpcCodecError::LengthMismatch {
331            declared: seq_offset + 4,
332            actual: payload.len(),
333        });
334    }
335    let target = &payload[6..seq_offset];
336    let seq = u16::from_be_bytes([payload[seq_offset], payload[seq_offset + 1]]);
337    let total = u16::from_be_bytes([payload[seq_offset + 2], payload[seq_offset + 3]]);
338    if total == 0 || seq >= total || total as usize > MAX_REQUEST_FRAGMENTS {
339        return Err(RpcCodecError::BadFragmentIndex { index: seq, total });
340    }
341    Ok(RequestFragment {
342        id,
343        method,
344        target,
345        seq,
346        total,
347        chunk: &payload[seq_offset + 4..],
348    })
349}
350
351/// Split a reassembled request object into its fields.
352fn decode_request_object(
353    id: u32,
354    method: RpcMethod,
355    target: &[u8],
356    object: &[u8],
357) -> Result<RpcRequest, RpcCodecError> {
358    let mut at = 0usize;
359    let take = |at: &mut usize, n: usize| -> Result<std::ops::Range<usize>, RpcCodecError> {
360        let end = at.checked_add(n).filter(|end| *end <= object.len()).ok_or(
361            RpcCodecError::LengthMismatch {
362                declared: at.saturating_add(n),
363                actual: object.len(),
364            },
365        )?;
366        let range = *at..end;
367        *at = end;
368        Ok(range)
369    };
370    let r = take(&mut at, 2)?;
371    let path_len = u16::from_be_bytes([object[r.start], object[r.start + 1]]) as usize;
372    let path = take(&mut at, path_len)?;
373    let r = take(&mut at, 1)?;
374    let ct_len = object[r.start] as usize;
375    let content_type = take(&mut at, ct_len)?;
376    let r = take(&mut at, 2)?;
377    let ticket_len = u16::from_be_bytes([object[r.start], object[r.start + 1]]) as usize;
378    if ticket_len > MAX_REQUEST_TICKET {
379        return Err(RpcCodecError::LengthMismatch {
380            declared: ticket_len,
381            actual: MAX_REQUEST_TICKET,
382        });
383    }
384    let ticket = take(&mut at, ticket_len)?;
385    let body = &object[at..];
386    if body.len() > MAX_REQUEST_BODY {
387        return Err(RpcCodecError::LengthMismatch {
388            declared: body.len(),
389            actual: MAX_REQUEST_BODY,
390        });
391    }
392    Ok(RpcRequest {
393        id,
394        method,
395        target: target.to_vec(),
396        path: object[path].to_vec(),
397        content_type: object[content_type].to_vec(),
398        ticket: object[ticket].to_vec(),
399        body: body.to_vec(),
400    })
401}
402
403/// What one pushed fragment settled.
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub enum ReassemblyOutcome {
406    /// Every fragment is in and the object decoded.
407    Complete(RpcRequest),
408    /// More fragments are needed.
409    Pending,
410    /// Already held, or a fragment of a request this reassembler dropped.
411    Duplicate,
412    /// Dropped: the request would exceed a buffering bound, or its object did
413    /// not decode.
414    Rejected,
415}
416
417struct PartialRequest {
418    method: RpcMethod,
419    target: Vec<u8>,
420    total: u16,
421    chunks: Vec<Option<Vec<u8>>>,
422    received: u16,
423    bytes: usize,
424    started: std::time::Instant,
425}
426
427/// Collects request fragments on the drone until a request is whole.
428///
429/// Bounded in count, bytes and time: the uplink is a broadcast any fleet member
430/// can transmit on, and nothing in a fragment is authenticated until the whole
431/// object (and the ticket inside it) has arrived.
432#[derive(Default)]
433pub struct RequestReassembler {
434    partials: std::collections::HashMap<u32, PartialRequest>,
435    buffered: usize,
436}
437
438impl RequestReassembler {
439    pub fn new() -> Self {
440        Self::default()
441    }
442
443    /// Feed one fragment received at `now`.
444    pub fn push(
445        &mut self,
446        fragment: &RequestFragment<'_>,
447        now: std::time::Instant,
448    ) -> ReassemblyOutcome {
449        self.expire(now);
450        if fragment.total == 1 {
451            return match decode_request_object(
452                fragment.id,
453                fragment.method,
454                fragment.target,
455                fragment.chunk,
456            ) {
457                Ok(request) => ReassemblyOutcome::Complete(request),
458                Err(_) => ReassemblyOutcome::Rejected,
459            };
460        }
461        if fragment.chunk.len() > MAX_REQUEST_CHUNK {
462            return ReassemblyOutcome::Rejected;
463        }
464        // A fragment that disagrees with the partial already held under its id
465        // is a different request reusing the id (a restarted ground station);
466        // the newer one wins.
467        let conflicting = self.partials.get(&fragment.id).is_some_and(|p| {
468            p.method != fragment.method || p.total != fragment.total || p.target != fragment.target
469        });
470        if conflicting {
471            self.drop_partial(fragment.id);
472        }
473        if !self.partials.contains_key(&fragment.id) {
474            if self.partials.len() >= MAX_PARTIAL_REQUESTS {
475                return ReassemblyOutcome::Rejected;
476            }
477            self.partials.insert(
478                fragment.id,
479                PartialRequest {
480                    method: fragment.method,
481                    target: fragment.target.to_vec(),
482                    total: fragment.total,
483                    chunks: vec![None; fragment.total as usize],
484                    received: 0,
485                    bytes: 0,
486                    started: now,
487                },
488            );
489        }
490        let buffered = self.buffered;
491        let Some(partial) = self.partials.get_mut(&fragment.id) else {
492            return ReassemblyOutcome::Rejected;
493        };
494        if partial.chunks[fragment.seq as usize].is_some() {
495            return ReassemblyOutcome::Duplicate;
496        }
497        if buffered + fragment.chunk.len() > MAX_PARTIAL_REQUEST_BYTES
498            || partial.bytes + fragment.chunk.len() > MAX_REQUEST_OBJECT
499        {
500            self.drop_partial(fragment.id);
501            return ReassemblyOutcome::Rejected;
502        }
503        partial.chunks[fragment.seq as usize] = Some(fragment.chunk.to_vec());
504        partial.received += 1;
505        partial.bytes += fragment.chunk.len();
506        self.buffered += fragment.chunk.len();
507        if partial.received < partial.total {
508            return ReassemblyOutcome::Pending;
509        }
510        let Some(done) = self.partials.remove(&fragment.id) else {
511            return ReassemblyOutcome::Rejected;
512        };
513        self.buffered = self.buffered.saturating_sub(done.bytes);
514        let mut object = Vec::with_capacity(done.bytes);
515        for chunk in done.chunks.into_iter().flatten() {
516            object.extend_from_slice(&chunk);
517        }
518        match decode_request_object(fragment.id, done.method, &done.target, &object) {
519            Ok(request) => ReassemblyOutcome::Complete(request),
520            Err(_) => ReassemblyOutcome::Rejected,
521        }
522    }
523
524    /// Requests currently partially held.
525    pub fn partial_count(&self) -> usize {
526        self.partials.len()
527    }
528
529    fn drop_partial(&mut self, id: u32) {
530        if let Some(p) = self.partials.remove(&id) {
531            self.buffered = self.buffered.saturating_sub(p.bytes);
532        }
533    }
534
535    fn expire(&mut self, now: std::time::Instant) {
536        let stale: Vec<u32> = self
537            .partials
538            .iter()
539            .filter(|(_, p)| now.saturating_duration_since(p.started) > REQUEST_REASSEMBLY_TIMEOUT)
540            .map(|(id, _)| *id)
541            .collect();
542        for id in stale {
543            self.drop_partial(id);
544        }
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use std::time::{Duration, Instant};
552
553    fn parts<'a>(path: &'a [u8], body: &'a [u8]) -> RequestParts<'a> {
554        RequestParts {
555            path,
556            content_type: b"",
557            ticket: b"",
558            body,
559        }
560    }
561
562    /// Encode and reassemble in order, the happy path a radio delivers.
563    fn round_trip(method: RpcMethod, id: u32, target: &[u8], req: &RequestParts<'_>) -> RpcRequest {
564        let frags = encode_request(method, id, target, req).unwrap();
565        let mut r = RequestReassembler::new();
566        let now = Instant::now();
567        let mut done = None;
568        for f in &frags {
569            if let ReassemblyOutcome::Complete(req) =
570                r.push(&decode_request_fragment(f).unwrap(), now)
571            {
572                done = Some(req);
573            }
574        }
575        done.expect("the request completes")
576    }
577
578    #[test]
579    fn a_small_request_is_one_fragment_and_round_trips() {
580        let req = RequestParts {
581            path: b"/api/config",
582            content_type: b"application/json",
583            ticket: b"v2|tok",
584            body: br#"{"key":"agent.name","value":"example-drone"}"#,
585        };
586        let frags = encode_request(RpcMethod::Put, 7, b"0a1b2c3d4e5f", &req).unwrap();
587        assert_eq!(frags.len(), 1);
588        assert!(frags[0].len() <= AUX_MAX_PAYLOAD);
589        let got = round_trip(RpcMethod::Put, 7, b"0a1b2c3d4e5f", &req);
590        assert_eq!(got.id, 7);
591        assert_eq!(got.method, RpcMethod::Put);
592        assert_eq!(got.target, b"0a1b2c3d4e5f");
593        assert_eq!(got.path, req.path);
594        assert_eq!(got.content_type, req.content_type);
595        assert_eq!(got.ticket, req.ticket);
596        assert_eq!(got.body, req.body);
597    }
598
599    #[test]
600    fn a_body_far_past_one_frame_fragments_and_reassembles_out_of_order() {
601        // Plugin uploads and larger writes used to be refused with a 413.
602        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
603        let req = RequestParts {
604            path: b"/api/plugins/com.example.tool/x/upload",
605            content_type: b"application/octet-stream",
606            ticket: b"",
607            body: &body,
608        };
609        let frags = encode_request(RpcMethod::Patch, 9, b"d1", &req).unwrap();
610        assert!(frags.len() > 100);
611        assert!(frags.iter().all(|f| f.len() <= AUX_MAX_PAYLOAD));
612        let mut r = RequestReassembler::new();
613        let now = Instant::now();
614        let mut order: Vec<usize> = (0..frags.len()).rev().collect();
615        // A duplicate in the middle of the stream must not count twice.
616        order.insert(5, order[3]);
617        let mut done = None;
618        for i in order {
619            match r.push(&decode_request_fragment(&frags[i]).unwrap(), now) {
620                ReassemblyOutcome::Complete(req) => done = Some(req),
621                ReassemblyOutcome::Pending | ReassemblyOutcome::Duplicate => {}
622                ReassemblyOutcome::Rejected => panic!("rejected"),
623            }
624        }
625        let got = done.expect("complete");
626        assert_eq!(got.method, RpcMethod::Patch);
627        assert_eq!(got.body, body);
628        assert_eq!(got.content_type, b"application/octet-stream");
629        assert_eq!(r.partial_count(), 0);
630    }
631
632    #[test]
633    fn a_body_over_one_mebibyte_is_refused_at_encode() {
634        let body = vec![0u8; MAX_REQUEST_BODY + 1];
635        assert_eq!(
636            encode_request(RpcMethod::Post, 1, b"d1", &parts(b"/api/x", &body)),
637            Err(RequestEncodeError::BodyTooLarge)
638        );
639        let body = vec![0u8; MAX_REQUEST_BODY];
640        assert!(encode_request(RpcMethod::Post, 1, b"d1", &parts(b"/api/x", &body)).is_ok());
641    }
642
643    #[test]
644    fn a_partial_request_is_dropped_after_the_reassembly_window() {
645        let body = vec![1u8; 5_000];
646        let frags = encode_request(RpcMethod::Post, 3, b"d1", &parts(b"/api/x", &body)).unwrap();
647        let mut r = RequestReassembler::new();
648        let t0 = Instant::now();
649        assert_eq!(
650            r.push(&decode_request_fragment(&frags[0]).unwrap(), t0),
651            ReassemblyOutcome::Pending
652        );
653        let later = t0 + REQUEST_REASSEMBLY_TIMEOUT + Duration::from_secs(1);
654        for f in &frags[1..] {
655            assert_eq!(
656                r.push(&decode_request_fragment(f).unwrap(), later),
657                ReassemblyOutcome::Pending,
658                "the stale first fragment is gone, so this cannot complete"
659            );
660        }
661    }
662
663    #[test]
664    fn concurrent_partials_are_bounded() {
665        let body = vec![1u8; 3_000];
666        let mut r = RequestReassembler::new();
667        let now = Instant::now();
668        for id in 0..MAX_PARTIAL_REQUESTS as u32 {
669            let frags =
670                encode_request(RpcMethod::Post, id, b"d1", &parts(b"/api/x", &body)).unwrap();
671            assert_eq!(
672                r.push(&decode_request_fragment(&frags[0]).unwrap(), now),
673                ReassemblyOutcome::Pending
674            );
675        }
676        let frags = encode_request(RpcMethod::Post, 999, b"d1", &parts(b"/api/x", &body)).unwrap();
677        assert_eq!(
678            r.push(&decode_request_fragment(&frags[0]).unwrap(), now),
679            ReassemblyOutcome::Rejected
680        );
681    }
682
683    #[test]
684    fn rejects_a_target_longer_than_a_device_id() {
685        let too_long = vec![b'a'; MAX_DEVICE_ID + 1];
686        assert_eq!(
687            encode_request(RpcMethod::Get, 1, &too_long, &parts(b"/api/x", b"")),
688            Err(RequestEncodeError::FieldTooLong)
689        );
690        let fits = vec![b'a'; MAX_DEVICE_ID];
691        assert_eq!(
692            round_trip(RpcMethod::Get, 1, &fits, &parts(b"/api/x", b"")).target,
693            fits
694        );
695    }
696
697    #[test]
698    fn rejects_a_truncated_fragment() {
699        let frags =
700            encode_request(RpcMethod::Post, 9, b"abc", &parts(b"/api/x", b"payload")).unwrap();
701        assert_eq!(
702            decode_request_fragment(&frags[0][..5]).unwrap_err(),
703            RpcCodecError::TooShort
704        );
705    }
706
707    #[test]
708    fn an_impossible_sequence_number_is_refused() {
709        let mut frag =
710            encode_request(RpcMethod::Get, 1, b"", &parts(b"/api/x", b"")).unwrap()[0].clone();
711        // seq (bytes 6..8 with an empty target) set past total.
712        frag[6..8].copy_from_slice(&3u16.to_be_bytes());
713        assert_eq!(
714            decode_request_fragment(&frag).unwrap_err(),
715            RpcCodecError::BadFragmentIndex { index: 3, total: 1 }
716        );
717    }
718
719    #[test]
720    fn an_object_whose_lengths_lie_is_rejected() {
721        let mut frag =
722            encode_request(RpcMethod::Get, 1, b"", &parts(b"/api/x", b"")).unwrap()[0].clone();
723        // path_len (first two chunk bytes) claims more than is present.
724        frag[10..12].copy_from_slice(&500u16.to_be_bytes());
725        let mut r = RequestReassembler::new();
726        assert_eq!(
727            r.push(&decode_request_fragment(&frag).unwrap(), Instant::now()),
728            ReassemblyOutcome::Rejected
729        );
730    }
731
732    #[test]
733    fn rejects_an_unknown_method_byte() {
734        let mut frag =
735            encode_request(RpcMethod::Get, 1, b"", &parts(b"/api/x", b"")).unwrap()[0].clone();
736        frag[0] = 0xFF;
737        assert_eq!(
738            decode_request_fragment(&frag).unwrap_err(),
739            RpcCodecError::BadMethod(0xFF)
740        );
741    }
742
743    #[test]
744    fn method_numbers_are_pinned() {
745        // Renumbering would silently reroute a POST into a GET handler.
746        assert_eq!(RpcMethod::Get as u8, 1);
747        assert_eq!(RpcMethod::Post as u8, 2);
748        assert_eq!(RpcMethod::Put as u8, 3);
749        assert_eq!(RpcMethod::Delete as u8, 4);
750        assert_eq!(RpcMethod::Patch as u8, 5);
751        assert_eq!(RpcMethod::from_u8(5), Some(RpcMethod::Patch));
752        assert_eq!(RpcMethod::from_u8(0), None);
753        for m in [
754            RpcMethod::Get,
755            RpcMethod::Post,
756            RpcMethod::Put,
757            RpcMethod::Delete,
758            RpcMethod::Patch,
759        ] {
760            assert_eq!(RpcMethod::from_http_method(m.as_http_method()), Some(m));
761        }
762        assert_eq!(RpcMethod::from_http_method("OPTIONS"), None);
763    }
764}