Skip to main content

ados_mavlink_tunnel/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(missing_docs)]
3
4use std::collections::HashMap;
5use std::fmt;
6use std::time::{Duration, Instant};
7
8/// Chunk-header format version. Bump on an incompatible header change; the
9/// parser rejects an unrecognised version rather than misreading it.
10pub const L3_VERSION: u8 = 1;
11
12/// Fixed chunk-header length in bytes (magic 2 + version 1 + flags 1 +
13/// request_id 4 + seq 2 + total 2).
14pub const L3_HEADER_LEN: usize = 12;
15
16/// The MAVLink TUNNEL single-frame payload cap (128 bytes) this crate chunks
17/// under.
18pub const TUNNEL_PAYLOAD_CAP: usize = 128;
19
20/// Largest body slice one chunk can carry (the payload cap minus the header).
21pub const MAX_CHUNK_DATA: usize = TUNNEL_PAYLOAD_CAP - L3_HEADER_LEN;
22
23// Compile-time invariant: the header plus the data cap exactly fill the
24// tunnel payload budget.
25const _: () = assert!(L3_HEADER_LEN + MAX_CHUNK_DATA == TUNNEL_PAYLOAD_CAP);
26
27const FLAG_IS_RESPONSE: u8 = 0b0000_0001;
28const FLAG_IS_ERROR: u8 = 0b0000_0010;
29
30/// A build or chunking failure with a stable, human-readable message.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TunnelError {
33    /// A chunk's data slice exceeds [`MAX_CHUNK_DATA`].
34    ChunkTooLarge {
35        /// Length of the rejected data slice.
36        got: usize,
37    },
38    /// `total` was zero, or `seq >= total`: an impossible chunk index.
39    BadChunkIndex {
40        /// The rejected chunk index.
41        seq: u16,
42        /// The rejected chunk count.
43        total: u16,
44    },
45    /// A body would split into more chunks than the caller's ceiling allows.
46    TooManyChunks {
47        /// Chunks the body needs.
48        needed: usize,
49        /// The effective chunk ceiling.
50        limit: usize,
51    },
52}
53
54impl fmt::Display for TunnelError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::ChunkTooLarge { got } => write!(
58                f,
59                "chunk data is {got} bytes, exceeds the {MAX_CHUNK_DATA}-byte chunk limit"
60            ),
61            Self::BadChunkIndex { seq, total } => {
62                write!(f, "chunk index seq={seq} is out of range for total={total}")
63            }
64            Self::TooManyChunks { needed, limit } => {
65                write!(
66                    f,
67                    "body needs {needed} chunks, exceeds the {limit}-chunk limit"
68                )
69            }
70        }
71    }
72}
73
74impl std::error::Error for TunnelError {}
75
76/// The parsed fields of one chunk header.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct ChunkHeader {
79    /// Correlation id echoed request↔response so the far side matches a reply
80    /// to its request over the shared shared link.
81    pub request_id: u32,
82    /// This chunk's 0-based index within the message.
83    pub seq: u16,
84    /// Total number of chunks the message spans.
85    pub total: u16,
86    /// True on a response, false on a request.
87    pub is_response: bool,
88    /// True when the (response) body is an error payload rather than a
89    /// successful result.
90    pub is_error: bool,
91}
92
93impl ChunkHeader {
94    fn flags(&self) -> u8 {
95        let mut f = 0u8;
96        if self.is_response {
97            f |= FLAG_IS_RESPONSE;
98        }
99        if self.is_error {
100            f |= FLAG_IS_ERROR;
101        }
102        f
103    }
104}
105
106/// The per-application chunk framing: the two magic bytes that open every
107/// chunk header.
108///
109/// Several applications may share one private TUNNEL `payload_type`, so the
110/// parser checks this magic (plus [`L3_VERSION`]) and ignores any payload that
111/// does not carry it. Pick a magic unique to your application.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct Framing {
114    /// The first two bytes of every chunk payload.
115    pub magic: [u8; 2],
116}
117
118impl Framing {
119    /// Serialize one chunk payload (`header || data`), ready to hand to
120    /// the outer MAVLink TUNNEL frame builder as the payload.
121    ///
122    /// Rejects a `data` slice longer than [`MAX_CHUNK_DATA`] and an impossible
123    /// `(seq, total)` (`total == 0` or `seq >= total`).
124    pub fn build_chunk(&self, header: &ChunkHeader, data: &[u8]) -> Result<Vec<u8>, TunnelError> {
125        if data.len() > MAX_CHUNK_DATA {
126            return Err(TunnelError::ChunkTooLarge { got: data.len() });
127        }
128        if header.total == 0 || header.seq >= header.total {
129            return Err(TunnelError::BadChunkIndex {
130                seq: header.seq,
131                total: header.total,
132            });
133        }
134        let mut out = Vec::with_capacity(L3_HEADER_LEN + data.len());
135        out.extend_from_slice(&self.magic);
136        out.push(L3_VERSION);
137        out.push(header.flags());
138        out.extend_from_slice(&header.request_id.to_le_bytes());
139        out.extend_from_slice(&header.seq.to_le_bytes());
140        out.extend_from_slice(&header.total.to_le_bytes());
141        out.extend_from_slice(data);
142        Ok(out)
143    }
144
145    /// Parse one chunk payload, returning the header and a borrow of the data
146    /// slice. Returns `None` (never an error) for any payload that is not a
147    /// well-formed chunk for this framing (too short, wrong magic, wrong version, or an
148    /// impossible `(seq, total)`), so a demux can ignore non-conforming traffic
149    /// sharing the same `payload_type` without a decision to make.
150    #[must_use]
151    pub fn parse_chunk<'a>(&self, payload: &'a [u8]) -> Option<(ChunkHeader, &'a [u8])> {
152        if payload.len() < L3_HEADER_LEN {
153            return None;
154        }
155        if payload[0..2] != self.magic || payload[2] != L3_VERSION {
156            return None;
157        }
158        let flags = payload[3];
159        let request_id = u32::from_le_bytes([payload[4], payload[5], payload[6], payload[7]]);
160        let seq = u16::from_le_bytes([payload[8], payload[9]]);
161        let total = u16::from_le_bytes([payload[10], payload[11]]);
162        if total == 0 || seq >= total {
163            return None;
164        }
165        let header = ChunkHeader {
166            request_id,
167            seq,
168            total,
169            is_response: flags & FLAG_IS_RESPONSE != 0,
170            is_error: flags & FLAG_IS_ERROR != 0,
171        };
172        Some((header, &payload[L3_HEADER_LEN..]))
173    }
174
175    /// Split a message body into ordered chunk payloads, each ready for a TUNNEL
176    /// frame. An empty body yields a single zero-length chunk (`total = 1`) so the
177    /// far side still sees exactly one message. Rejects a body that would exceed
178    /// `max_chunks` frames.
179    pub fn chunk_message(
180        &self,
181        request_id: u32,
182        is_response: bool,
183        is_error: bool,
184        body: &[u8],
185        max_chunks: usize,
186    ) -> Result<Vec<Vec<u8>>, TunnelError> {
187        // ceil(len / MAX_CHUNK_DATA), but at least one chunk (an empty body still
188        // travels as one zero-length chunk).
189        let needed = if body.is_empty() {
190            1
191        } else {
192            body.len().div_ceil(MAX_CHUNK_DATA)
193        };
194        if needed > max_chunks || needed > u16::MAX as usize {
195            return Err(TunnelError::TooManyChunks {
196                needed,
197                limit: max_chunks.min(u16::MAX as usize),
198            });
199        }
200        let total = needed as u16;
201        let mut chunks = Vec::with_capacity(needed);
202        for seq in 0..needed {
203            let start = seq * MAX_CHUNK_DATA;
204            let end = (start + MAX_CHUNK_DATA).min(body.len());
205            let slice = if body.is_empty() {
206                &[][..]
207            } else {
208                &body[start..end]
209            };
210            let header = ChunkHeader {
211                request_id,
212                seq: seq as u16,
213                total,
214                is_response,
215                is_error,
216            };
217            chunks.push(self.build_chunk(&header, slice)?);
218        }
219        Ok(chunks)
220    }
221}
222
223/// A completed, reassembled message.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct CompletedMessage {
226    /// Correlation id carried by every chunk of the message.
227    pub request_id: u32,
228    /// The `is_response` flag of the message.
229    pub is_response: bool,
230    /// The `is_error` flag of the message.
231    pub is_error: bool,
232    /// The reassembled body (the concatenation of the chunk data slices).
233    pub body: Vec<u8>,
234}
235
236/// What [`Reassembler::push`] did with one chunk payload.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub enum PushOutcome {
239    /// The payload was not a conforming chunk (wrong magic, version or shape);
240    /// dropped, no state change worth acting on.
241    Ignored,
242    /// A valid chunk accepted; the message is not yet complete.
243    Incomplete,
244    /// The final missing chunk arrived; the whole message is returned and its
245    /// reassembly state is freed.
246    Complete(CompletedMessage),
247    /// The chunk was well-formed but breached a bound (too many chunks, or the
248    /// accumulated body exceeded the cap); the whole in-flight message is
249    /// dropped and freed. The string names why (for a truthful log/counter).
250    Rejected(String),
251}
252
253/// One in-flight message's partial state.
254#[derive(Debug)]
255struct Partial {
256    is_response: bool,
257    is_error: bool,
258    total: u16,
259    chunks: Vec<Option<Vec<u8>>>,
260    received: usize,
261    bytes: usize,
262    created: Instant,
263}
264
265/// A bounded, timeout-swept chunk reassembler keyed by `request_id`.
266///
267/// It is a shared receiver for a lossy link: several messages can be in flight
268/// at once, out of order. Every message is bounded: a `total` above
269/// `max_chunks` or an accumulated body above `max_body_bytes` is rejected and
270/// freed, and [`Reassembler::sweep`] drops any message that has not completed
271/// within a caller-supplied deadline. A peer flooding partial messages can
272/// never grow memory without bound.
273#[derive(Debug)]
274pub struct Reassembler {
275    framing: Framing,
276    pending: HashMap<u32, Partial>,
277    max_chunks: usize,
278    max_body_bytes: usize,
279}
280
281impl Reassembler {
282    /// Create a reassembler with the given per-message ceilings.
283    #[must_use]
284    pub fn new(framing: Framing, max_chunks: usize, max_body_bytes: usize) -> Self {
285        Self {
286            framing,
287            pending: HashMap::new(),
288            max_chunks,
289            max_body_bytes,
290        }
291    }
292
293    /// Number of in-flight (incomplete) messages, for introspection/telemetry.
294    #[must_use]
295    pub fn in_flight(&self) -> usize {
296        self.pending.len()
297    }
298
299    /// Feed one chunk payload (the inner TUNNEL payload, not the whole frame)
300    /// with the current clock reading. See [`PushOutcome`].
301    pub fn push(&mut self, payload: &[u8], now: Instant) -> PushOutcome {
302        let Some((header, data)) = self.framing.parse_chunk(payload) else {
303            return PushOutcome::Ignored;
304        };
305        if header.total as usize > self.max_chunks {
306            // Never allocate a chunk vector for an over-cap message; also drop
307            // any prior in-flight state under this id.
308            self.pending.remove(&header.request_id);
309            return PushOutcome::Rejected(format!(
310                "total {} exceeds max_chunks {}",
311                header.total, self.max_chunks
312            ));
313        }
314
315        // Get the in-flight message under this id, replacing a stale one whose
316        // shape (total/direction/error) disagrees: that is a different
317        // message reusing the id after the old one never completed.
318        let fresh = match self.pending.get(&header.request_id) {
319            Some(p) => {
320                p.total != header.total
321                    || p.is_response != header.is_response
322                    || p.is_error != header.is_error
323            }
324            None => true,
325        };
326        if fresh {
327            self.pending.insert(
328                header.request_id,
329                Partial {
330                    is_response: header.is_response,
331                    is_error: header.is_error,
332                    total: header.total,
333                    chunks: vec![None; header.total as usize],
334                    received: 0,
335                    bytes: 0,
336                    created: now,
337                },
338            );
339        }
340        let partial = self
341            .pending
342            .get_mut(&header.request_id)
343            .expect("just inserted or already present");
344
345        // Place (or overwrite a duplicate) this chunk, keeping the byte count
346        // exact.
347        let slot = &mut partial.chunks[header.seq as usize];
348        if let Some(old) = slot.take() {
349            partial.bytes -= old.len();
350            partial.received -= 1;
351        }
352        partial.bytes += data.len();
353        partial.received += 1;
354        *slot = Some(data.to_vec());
355
356        if partial.bytes > self.max_body_bytes {
357            let bytes = partial.bytes;
358            let cap = self.max_body_bytes;
359            self.pending.remove(&header.request_id);
360            return PushOutcome::Rejected(format!("body {bytes} bytes exceeds cap {cap}"));
361        }
362
363        if partial.received < partial.total as usize {
364            return PushOutcome::Incomplete;
365        }
366
367        // Complete: assemble in seq order and free the entry.
368        let partial = self
369            .pending
370            .remove(&header.request_id)
371            .expect("present above");
372        let mut body = Vec::with_capacity(partial.bytes);
373        for chunk in partial.chunks {
374            body.extend_from_slice(&chunk.expect("received == total ⇒ every slot filled"));
375        }
376        PushOutcome::Complete(CompletedMessage {
377            request_id: header.request_id,
378            is_response: partial.is_response,
379            is_error: partial.is_error,
380            body,
381        })
382    }
383
384    /// Drop every in-flight message whose first chunk arrived more than
385    /// `timeout` before `now`. Returns the number dropped so a caller can
386    /// surface a truthful "N reassembly timeouts" counter.
387    pub fn sweep(&mut self, now: Instant, timeout: Duration) -> usize {
388        let before = self.pending.len();
389        self.pending
390            .retain(|_, p| now.duration_since(p.created) <= timeout);
391        before - self.pending.len()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    const F: Framing = Framing {
400        magic: [0x5A, 0x01],
401    };
402
403    fn hdr(request_id: u32, seq: u16, total: u16) -> ChunkHeader {
404        ChunkHeader {
405            request_id,
406            seq,
407            total,
408            is_response: false,
409            is_error: false,
410        }
411    }
412
413    #[test]
414    fn header_len_and_data_cap_fit_the_tunnel_budget() {
415        // The compile-time `const _` assertions above pin the budget/range
416        // invariants; this pins the concrete data cap the wire layout implies.
417        assert_eq!(L3_HEADER_LEN + MAX_CHUNK_DATA, TUNNEL_PAYLOAD_CAP);
418        assert_eq!(MAX_CHUNK_DATA, 116);
419    }
420
421    #[test]
422    fn build_then_parse_round_trips_header_and_data() {
423        let h = ChunkHeader {
424            request_id: 0xDEAD_BEEF,
425            seq: 3,
426            total: 9,
427            is_response: true,
428            is_error: true,
429        };
430        let data = b"the quick brown fox";
431        let payload = F.build_chunk(&h, data).unwrap();
432        assert_eq!(payload.len(), L3_HEADER_LEN + data.len());
433        let (got, got_data) = F.parse_chunk(&payload).unwrap();
434        assert_eq!(got, h);
435        assert_eq!(got_data, data);
436    }
437
438    #[test]
439    fn build_rejects_oversize_data_and_bad_index() {
440        let big = vec![0u8; MAX_CHUNK_DATA + 1];
441        assert_eq!(
442            F.build_chunk(&hdr(1, 0, 1), &big),
443            Err(TunnelError::ChunkTooLarge {
444                got: MAX_CHUNK_DATA + 1
445            })
446        );
447        assert!(matches!(
448            F.build_chunk(&hdr(1, 2, 2), b"x"),
449            Err(TunnelError::BadChunkIndex { seq: 2, total: 2 })
450        ));
451        assert!(matches!(
452            F.build_chunk(&hdr(1, 0, 0), b"x"),
453            Err(TunnelError::BadChunkIndex { seq: 0, total: 0 })
454        ));
455    }
456
457    #[test]
458    fn parse_ignores_non_conforming_payloads() {
459        assert!(F.parse_chunk(b"").is_none());
460        assert!(F.parse_chunk(&[0u8; L3_HEADER_LEN - 1]).is_none());
461        // Right length, wrong magic.
462        let mut bad = vec![0xFFu8; L3_HEADER_LEN];
463        bad[10] = 1; // total=1
464        assert!(F.parse_chunk(&bad).is_none());
465        // Right magic, wrong version.
466        let mut wrong_ver = F.build_chunk(&hdr(7, 0, 1), b"hi").unwrap();
467        wrong_ver[2] = L3_VERSION + 1;
468        assert!(F.parse_chunk(&wrong_ver).is_none());
469        // Right magic+version, impossible index (seq >= total).
470        let mut bad_idx = F.build_chunk(&hdr(7, 0, 2), b"hi").unwrap();
471        bad_idx[8] = 5; // seq=5, total=2
472        assert!(F.parse_chunk(&bad_idx).is_none());
473    }
474
475    #[test]
476    fn chunk_message_splits_and_reassembles_in_order() {
477        let body: Vec<u8> = (0..(MAX_CHUNK_DATA * 3 + 7)).map(|i| i as u8).collect();
478        let chunks = F.chunk_message(42, true, false, &body, 512).unwrap();
479        assert_eq!(chunks.len(), 4);
480        let mut re = Reassembler::new(F, 512, 1 << 20);
481        let now = Instant::now();
482        let mut done = None;
483        for c in &chunks {
484            match re.push(c, now) {
485                PushOutcome::Incomplete => {}
486                PushOutcome::Complete(m) => done = Some(m),
487                other => panic!("unexpected {other:?}"),
488            }
489        }
490        let m = done.expect("completed");
491        assert_eq!(m.request_id, 42);
492        assert!(m.is_response && !m.is_error);
493        assert_eq!(m.body, body);
494        assert_eq!(re.in_flight(), 0);
495    }
496
497    #[test]
498    fn empty_body_travels_as_one_chunk() {
499        let chunks = F.chunk_message(1, false, false, b"", 8).unwrap();
500        assert_eq!(chunks.len(), 1);
501        let mut re = Reassembler::new(F, 8, 64);
502        match re.push(&chunks[0], Instant::now()) {
503            PushOutcome::Complete(m) => assert!(m.body.is_empty()),
504            other => panic!("unexpected {other:?}"),
505        }
506    }
507
508    #[test]
509    fn reassembles_out_of_order_and_tolerates_duplicates() {
510        let body: Vec<u8> = (0..250u16).map(|i| i as u8).collect();
511        let chunks = F.chunk_message(99, false, false, &body, 512).unwrap();
512        assert_eq!(chunks.len(), 3);
513        let mut re = Reassembler::new(F, 512, 1 << 20);
514        let now = Instant::now();
515        // Out of order, with a duplicate of chunk 2 before chunk 0.
516        assert_eq!(re.push(&chunks[2], now), PushOutcome::Incomplete);
517        assert_eq!(re.push(&chunks[1], now), PushOutcome::Incomplete);
518        assert_eq!(re.push(&chunks[2], now), PushOutcome::Incomplete); // dup, no double-count
519        match re.push(&chunks[0], now) {
520            PushOutcome::Complete(m) => assert_eq!(m.body, body),
521            other => panic!("unexpected {other:?}"),
522        }
523    }
524
525    #[test]
526    fn a_lost_chunk_never_completes_and_sweeps_out() {
527        let body: Vec<u8> = vec![7u8; MAX_CHUNK_DATA * 2];
528        let chunks = F.chunk_message(3, false, false, &body, 8).unwrap();
529        let mut re = Reassembler::new(F, 8, 1 << 20);
530        let base = Instant::now();
531        assert_eq!(re.push(&chunks[0], base), PushOutcome::Incomplete);
532        // chunks[1] is "lost"; the message never completes.
533        assert_eq!(re.in_flight(), 1);
534        assert_eq!(
535            re.sweep(base + Duration::from_secs(9), Duration::from_secs(10)),
536            0
537        );
538        assert_eq!(
539            re.sweep(base + Duration::from_secs(11), Duration::from_secs(10)),
540            1
541        );
542        assert_eq!(re.in_flight(), 0);
543    }
544
545    #[test]
546    fn rejects_over_cap_total_and_over_cap_body() {
547        // total above max_chunks: rejected before allocating.
548        let chunk = F.build_chunk(&hdr(5, 0, 100), b"x").unwrap();
549        let mut re = Reassembler::new(F, 8, 1 << 20);
550        assert!(matches!(
551            re.push(&chunk, Instant::now()),
552            PushOutcome::Rejected(_)
553        ));
554        assert_eq!(re.in_flight(), 0);
555
556        // Accumulated body above the byte cap: the whole message is freed.
557        let body = vec![0u8; MAX_CHUNK_DATA * 3];
558        let chunks = F.chunk_message(6, false, false, &body, 512).unwrap();
559        let mut re = Reassembler::new(F, 512, MAX_CHUNK_DATA * 2); // cap below the body
560        let now = Instant::now();
561        assert_eq!(re.push(&chunks[0], now), PushOutcome::Incomplete);
562        assert_eq!(re.push(&chunks[1], now), PushOutcome::Incomplete);
563        assert!(matches!(re.push(&chunks[2], now), PushOutcome::Rejected(_)));
564        assert_eq!(re.in_flight(), 0);
565    }
566
567    #[test]
568    fn chunk_message_rejects_a_body_over_the_chunk_limit() {
569        let body = vec![0u8; MAX_CHUNK_DATA * 5];
570        assert!(matches!(
571            F.chunk_message(1, false, false, &body, 3),
572            Err(TunnelError::TooManyChunks {
573                needed: 5,
574                limit: 3
575            })
576        ));
577    }
578
579    #[test]
580    fn a_reused_id_with_a_new_shape_resets_the_prior_message() {
581        let mut re = Reassembler::new(F, 512, 1 << 20);
582        let now = Instant::now();
583        // Start a 3-chunk message under id 8, deliver one chunk.
584        let first = F
585            .chunk_message(8, false, false, &vec![1u8; MAX_CHUNK_DATA * 3], 512)
586            .unwrap();
587        assert_eq!(re.push(&first[0], now), PushOutcome::Incomplete);
588        assert_eq!(re.in_flight(), 1);
589        // A brand-new single-chunk message reuses id 8: it replaces the stale
590        // one and completes on its own.
591        let second = F.chunk_message(8, false, false, b"fresh", 512).unwrap();
592        match re.push(&second[0], now) {
593            PushOutcome::Complete(m) => assert_eq!(m.body, b"fresh"),
594            other => panic!("unexpected {other:?}"),
595        }
596        assert_eq!(re.in_flight(), 0);
597    }
598}