Skip to main content

zerodds_rtps/
fragment_assembler.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Fragment reassembly for DDSI-RTPS 2.5 §8.4.14 on the reader side.
4//!
5//! Keeps a `FragmentBuffer` per in-flight sample SN, into which DATA_FRAG
6//! submessages are fed. Once all fragments are present, a
7//! complete sample falls out, which the `ReliableReader` treats like a
8//! regular DATA.
9//!
10//! # DoS posture
11//!
12//! The assembler must robustly process input from untrusted writers.
13//! Three caps protect against pathological inputs:
14//!
15//! - `max_pending_sns`: maximum number of SNs simultaneously in progress.
16//!   On overflow the oldest (smallest) incomplete SN is discarded.
17//! - `max_sample_bytes`: upper bound for `sample_size`. DATA_FRAGs with
18//!   `sample_size > cap` are discarded **without** allocation —
19//!   protection against "I claim a 4 GB sample and hope you allocate".
20//! - `max_fragment_size`: upper bound for `fragment_size` values from the
21//!   writer. A typical MTU is < 1500; we accept up to 65535.
22//!
23//! Discarded fragments are counted in `drop_count` (diagnosis).
24
25extern crate alloc;
26use alloc::collections::{BTreeMap, BTreeSet};
27use alloc::vec;
28use alloc::vec::Vec;
29
30use crate::submessages::{DataFragSubmessage, FragmentNumberSet};
31use crate::wire_types::{FragmentNumber, SequenceNumber};
32
33/// Default cap for the number of simultaneously in-flight SNs.
34pub const DEFAULT_MAX_PENDING_SNS: usize = 64;
35/// Default cap for the maximum sample size (1 MiB). Larger samples
36/// are not a use case in phase 1; DDS-Security/fragmentation on
37/// large images waits for phase 2+.
38pub const DEFAULT_MAX_SAMPLE_BYTES: usize = 1024 * 1024;
39/// Default cap for `fragment_size` (u16 maximum per spec).
40pub const DEFAULT_MAX_FRAGMENT_SIZE: u16 = u16::MAX;
41
42/// A fully reassembled sample.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CompletedSample {
45    /// Writer sequence number.
46    pub sequence_number: SequenceNumber,
47    /// Reassembled payload (total sample bytes in original order).
48    pub payload: Vec<u8>,
49}
50
51/// Configuration for the assembler.
52#[derive(Debug, Clone, Copy)]
53pub struct AssemblerCaps {
54    /// Max. number of simultaneous SNs.
55    pub max_pending_sns: usize,
56    /// Max. sample_size in bytes.
57    pub max_sample_bytes: usize,
58    /// Max. fragment_size in bytes.
59    pub max_fragment_size: u16,
60}
61
62impl Default for AssemblerCaps {
63    /// Conservative defaults for typical DDS workloads (1 MiB samples,
64    /// 64 simultaneous in-flight SNs, u16-max fragment size).
65    fn default() -> Self {
66        Self {
67            max_pending_sns: DEFAULT_MAX_PENDING_SNS,
68            max_sample_bytes: DEFAULT_MAX_SAMPLE_BYTES,
69            max_fragment_size: DEFAULT_MAX_FRAGMENT_SIZE,
70        }
71    }
72}
73
74/// Per-SN ring buffer for incoming fragments.
75#[derive(Debug, Clone)]
76struct FragmentBuffer {
77    sample_size: u32,
78    fragment_size: u16,
79    total_fragments: u32,
80    received: BTreeSet<FragmentNumber>,
81    data: Vec<u8>,
82}
83
84impl FragmentBuffer {
85    fn new(sample_size: u32, fragment_size: u16) -> Self {
86        let total = if fragment_size == 0 {
87            0
88        } else {
89            sample_size.div_ceil(u32::from(fragment_size))
90        };
91        Self {
92            sample_size,
93            fragment_size,
94            total_fragments: total,
95            received: BTreeSet::new(),
96            data: vec![0u8; sample_size as usize],
97        }
98    }
99
100    fn is_complete(&self) -> bool {
101        self.total_fragments > 0 && self.received.len() as u32 == self.total_fragments
102    }
103
104    fn missing(&self) -> FragmentNumberSet {
105        if self.total_fragments == 0 {
106            return FragmentNumberSet::from_missing(FragmentNumber(1), &[]);
107        }
108        let mut missing_nums = Vec::new();
109        for f in 1..=self.total_fragments {
110            let fnum = FragmentNumber(f);
111            if !self.received.contains(&fnum) {
112                missing_nums.push(fnum);
113            }
114        }
115        let base = missing_nums
116            .first()
117            .copied()
118            .unwrap_or(FragmentNumber(self.total_fragments.saturating_add(1)));
119        FragmentNumberSet::from_missing(base, &missing_nums)
120    }
121}
122
123/// Rejected-fragment category — for diagnostics only.
124///
125/// **Adding new variants**: when changing this, also adapt the
126/// [`DropReason::as_str`] method (exhaustive match), otherwise
127/// the build breaks. This is intentional — it prevents new
128/// failure modes from being silently lost in logging/metrics paths.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum DropReason {
132    /// `sample_size` over the cap.
133    SampleTooLarge,
134    /// `fragment_size` over the cap or == 0.
135    FragmentSizeInvalid,
136    /// `fragment_starting_num == 0` (1-based expected).
137    FragmentIndexZero,
138    /// Fragment index beyond `total_fragments`.
139    FragmentIndexOutOfRange,
140    /// Payload length does not match the fragment position.
141    PayloadSizeMismatch,
142    /// A later DataFrag contradicts an already stored one
143    /// (different `sample_size` or `fragment_size`).
144    InconsistentWithBuffered,
145    /// `fragments_in_submessage == 0` or inconsistent.
146    FragmentsInSubmessageInvalid,
147    /// The number of simultaneously managed SNs would exceed `max_pending_sns`
148    /// — the oldest incomplete SN was discarded.
149    PendingSnsCapExceeded,
150    /// `max_pending_sns == 0` — the assembler accepts no entries.
151    AssemblerDisabled,
152}
153
154impl DropReason {
155    /// Stable string representation for logging/metrics. Exhaustive
156    /// match — new variants intentionally break the build here.
157    #[must_use]
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::SampleTooLarge => "sample_too_large",
161            Self::FragmentSizeInvalid => "fragment_size_invalid",
162            Self::FragmentIndexZero => "fragment_index_zero",
163            Self::FragmentIndexOutOfRange => "fragment_index_out_of_range",
164            Self::PayloadSizeMismatch => "payload_size_mismatch",
165            Self::InconsistentWithBuffered => "inconsistent_with_buffered",
166            Self::FragmentsInSubmessageInvalid => "fragments_in_submessage_invalid",
167            Self::PendingSnsCapExceeded => "pending_sns_cap_exceeded",
168            Self::AssemblerDisabled => "assembler_disabled",
169        }
170    }
171}
172
173impl core::fmt::Display for DropReason {
174    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
175        f.write_str(self.as_str())
176    }
177}
178
179/// State of a reassembler.
180///
181/// `FragmentAssembler::default()` returns an assembler with
182/// [`AssemblerCaps::default`] — the only defaults path.
183#[derive(Debug, Clone, Default)]
184pub struct FragmentAssembler {
185    buffers: BTreeMap<SequenceNumber, FragmentBuffer>,
186    caps: AssemblerCaps,
187    drop_count: u64,
188    last_drop_reason: Option<DropReason>,
189}
190
191impl FragmentAssembler {
192    /// Creates an assembler with the given caps.
193    #[must_use]
194    pub fn new(caps: AssemblerCaps) -> Self {
195        Self {
196            buffers: BTreeMap::new(),
197            caps,
198            drop_count: 0,
199            last_drop_reason: None,
200        }
201    }
202
203    /// Number of active SNs.
204    #[must_use]
205    pub fn len(&self) -> usize {
206        self.buffers.len()
207    }
208
209    /// Is the assembler empty?
210    #[must_use]
211    pub fn is_empty(&self) -> bool {
212        self.buffers.is_empty()
213    }
214
215    /// Number of discarded fragments since start (or since
216    /// [`reset_diagnostics`](Self::reset_diagnostics)).
217    #[must_use]
218    pub fn drop_count(&self) -> u64 {
219        self.drop_count
220    }
221
222    /// The reason for the most recently discarded fragment, if any
223    /// was discarded at all. For debugging/metrics — not for
224    /// control-flow decisions.
225    #[must_use]
226    pub fn last_drop_reason(&self) -> Option<DropReason> {
227        self.last_drop_reason
228    }
229
230    /// Resets the diagnostic counters to 0. The `buffers` stay
231    /// unchanged — this is pure metric hygiene (long-running readers
232    /// want their delta snapshots).
233    pub fn reset_diagnostics(&mut self) {
234        self.drop_count = 0;
235        self.last_drop_reason = None;
236    }
237
238    /// True if fragments are missing for at least one SN.
239    #[must_use]
240    pub fn has_gaps(&self) -> bool {
241        self.buffers.values().any(|b| !b.is_complete())
242    }
243
244    /// Iterates SNs for which fragment gaps currently exist.
245    pub fn incomplete_sns(&self) -> impl Iterator<Item = SequenceNumber> + '_ {
246        self.buffers
247            .iter()
248            .filter(|(_, b)| !b.is_complete())
249            .map(|(sn, _)| *sn)
250    }
251
252    /// Missing fragments for an SN. Returns an empty set if the SN is
253    /// unknown or already complete.
254    #[must_use]
255    pub fn missing_fragments(&self, sn: SequenceNumber) -> FragmentNumberSet {
256        match self.buffers.get(&sn) {
257            Some(b) => b.missing(),
258            None => FragmentNumberSet::from_missing(FragmentNumber(1), &[]),
259        }
260    }
261
262    /// Removes the buffer for this SN (e.g. on a GAP signal or after
263    /// completion). Returns whether the buffer was present.
264    pub fn discard(&mut self, sn: SequenceNumber) -> bool {
265        self.buffers.remove(&sn).is_some()
266    }
267
268    /// Feeds in a DATA_FRAG. Returns the reassembled sample on
269    /// completion.
270    ///
271    /// Inconsistent or pathological fragments are discarded and
272    /// counted in `drop_count` — they cannot make the internal map
273    /// grow beyond the caps.
274    pub fn insert(&mut self, df: &DataFragSubmessage) -> Option<CompletedSample> {
275        // --- Input validation (no-alloc gate) ----------------------
276        if df.fragment_size == 0 || df.fragment_size > self.caps.max_fragment_size {
277            self.record_drop(DropReason::FragmentSizeInvalid);
278            return None;
279        }
280        if df.fragments_in_submessage == 0 {
281            self.record_drop(DropReason::FragmentsInSubmessageInvalid);
282            return None;
283        }
284        if df.sample_size as usize > self.caps.max_sample_bytes {
285            self.record_drop(DropReason::SampleTooLarge);
286            return None;
287        }
288        if df.fragment_starting_num.0 == 0 {
289            self.record_drop(DropReason::FragmentIndexZero);
290            return None;
291        }
292
293        // Pre-computations
294        let total_fragments = df.sample_size.div_ceil(u32::from(df.fragment_size));
295        let last_frag = df
296            .fragment_starting_num
297            .0
298            .checked_add(u32::from(df.fragments_in_submessage) - 1)
299            .unwrap_or(u32::MAX);
300        if last_frag > total_fragments {
301            self.record_drop(DropReason::FragmentIndexOutOfRange);
302            return None;
303        }
304
305        // Cap: number of simultaneously in-flight SNs.
306        if !self.buffers.contains_key(&df.writer_sn)
307            && self.buffers.len() >= self.caps.max_pending_sns
308        {
309            // Discard the oldest SN — DoS protection. The affected sample
310            // is gone; the reader must treat this like a GAP state.
311            let Some(&oldest) = self.buffers.keys().next() else {
312                // Cap == 0: nobody may enter.
313                self.record_drop(DropReason::AssemblerDisabled);
314                return None;
315            };
316            self.buffers.remove(&oldest);
317            self.record_drop(DropReason::PendingSnsCapExceeded);
318        }
319
320        // Create the buffer or extend it consistently.
321        let buffer = match self.buffers.get_mut(&df.writer_sn) {
322            Some(existing) => {
323                if existing.sample_size != df.sample_size
324                    || existing.fragment_size != df.fragment_size
325                {
326                    self.record_drop(DropReason::InconsistentWithBuffered);
327                    return None;
328                }
329                existing
330            }
331            None => {
332                self.buffers.insert(
333                    df.writer_sn,
334                    FragmentBuffer::new(df.sample_size, df.fragment_size),
335                );
336                self.buffers.get_mut(&df.writer_sn)?
337            }
338        };
339
340        // Write the fragment bytes to the correct position.
341        let frag_size_usize = buffer.fragment_size as usize;
342        let frag_count = df.fragments_in_submessage as usize;
343        let first_idx = (df.fragment_starting_num.0 - 1) as usize;
344        let byte_start = first_idx * frag_size_usize;
345        let expected_last_frag = core::cmp::min(last_frag, buffer.total_fragments);
346        // Expected payload length: frag_count-1 full fragments + possibly a
347        // shortened last fragment (if last_frag == total_fragments).
348        let full_portion = (frag_count - 1) * frag_size_usize;
349        let tail_size = if expected_last_frag == buffer.total_fragments {
350            // The last fragment of the sample may be shorter.
351            buffer.sample_size as usize - ((buffer.total_fragments - 1) as usize) * frag_size_usize
352        } else {
353            frag_size_usize
354        };
355        let expected_len = full_portion + tail_size;
356        // Cross-vendor: cyclone/FastDDS pad the LAST fragment of a
357        // sample to alignment (e.g. 72 instead of 71 bytes). The overhang is
358        // padding that does not belong to the sample (`sample_size`). We tolerate
359        // a longer last fragment and copy only `expected_len`
360        // sample bytes; non-last fragments stay exact (strict cap).
361        let is_last = expected_last_frag == buffer.total_fragments;
362        let too_short = df.serialized_payload.len() < expected_len;
363        let non_last_mismatch = !is_last && df.serialized_payload.len() != expected_len;
364        if too_short || non_last_mismatch {
365            self.record_drop(DropReason::PayloadSizeMismatch);
366            return None;
367        }
368
369        // Write — only the `expected_len` sample bytes (trailing padding of
370        // the last fragment is discarded).
371        let data_end = byte_start + expected_len;
372        if data_end > buffer.data.len() {
373            self.record_drop(DropReason::PayloadSizeMismatch);
374            return None;
375        }
376        buffer.data[byte_start..data_end].copy_from_slice(&df.serialized_payload[..expected_len]);
377        for f in 0..df.fragments_in_submessage as u32 {
378            buffer
379                .received
380                .insert(FragmentNumber(df.fragment_starting_num.0 + f));
381        }
382
383        if buffer.is_complete() {
384            // Take the buffer and return a CompletedSample.
385            let buf = self.buffers.remove(&df.writer_sn)?;
386            return Some(CompletedSample {
387                sequence_number: df.writer_sn,
388                payload: buf.data,
389            });
390        }
391        None
392    }
393
394    fn record_drop(&mut self, reason: DropReason) {
395        self.drop_count = self.drop_count.saturating_add(1);
396        self.last_drop_reason = Some(reason);
397    }
398}
399
400#[cfg(test)]
401#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
402mod tests {
403    use super::*;
404    use crate::wire_types::EntityId;
405
406    fn wid() -> EntityId {
407        EntityId::user_writer_with_key([0x10, 0x20, 0x30])
408    }
409    fn rid() -> EntityId {
410        EntityId::user_reader_with_key([0x40, 0x50, 0x60])
411    }
412
413    fn df(
414        sn: i64,
415        starting: u32,
416        count: u16,
417        frag_size: u16,
418        sample_size: u32,
419        payload: Vec<u8>,
420    ) -> DataFragSubmessage {
421        DataFragSubmessage {
422            extra_flags: 0,
423            reader_id: rid(),
424            writer_id: wid(),
425            writer_sn: SequenceNumber(sn),
426            fragment_starting_num: FragmentNumber(starting),
427            fragments_in_submessage: count,
428            fragment_size: frag_size,
429            sample_size,
430            serialized_payload: alloc::sync::Arc::from(payload),
431            inline_qos_flag: false,
432            hash_key_flag: false,
433            key_flag: false,
434            non_standard_flag: false,
435        }
436    }
437
438    #[test]
439    fn single_fragment_sample_completes_immediately() {
440        let mut a = FragmentAssembler::default();
441        // sample_size=4, frag_size=4 → 1 fragment
442        let res = a.insert(&df(1, 1, 1, 4, 4, vec![1, 2, 3, 4]));
443        assert!(res.is_some());
444        let s = res.unwrap();
445        assert_eq!(s.sequence_number, SequenceNumber(1));
446        assert_eq!(s.payload, vec![1, 2, 3, 4]);
447        assert_eq!(a.len(), 0);
448    }
449
450    #[test]
451    fn two_fragments_complete_in_order() {
452        let mut a = FragmentAssembler::default();
453        assert!(a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4])).is_none());
454        let res = a.insert(&df(1, 2, 1, 4, 8, vec![5, 6, 7, 8])).unwrap();
455        assert_eq!(res.payload, vec![1, 2, 3, 4, 5, 6, 7, 8]);
456    }
457
458    #[test]
459    fn fragments_complete_out_of_order() {
460        let mut a = FragmentAssembler::default();
461        // 2 first, then 1, then 3
462        assert!(a.insert(&df(1, 2, 1, 4, 10, vec![5, 6, 7, 8])).is_none());
463        assert!(a.insert(&df(1, 1, 1, 4, 10, vec![1, 2, 3, 4])).is_none());
464        let res = a.insert(&df(1, 3, 1, 4, 10, vec![9, 10])).unwrap();
465        assert_eq!(res.payload, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
466    }
467
468    #[test]
469    fn last_fragment_shorter_than_fragment_size() {
470        let mut a = FragmentAssembler::default();
471        assert!(a.insert(&df(1, 1, 1, 4, 10, vec![1, 2, 3, 4])).is_none());
472        assert!(a.insert(&df(1, 2, 1, 4, 10, vec![5, 6, 7, 8])).is_none());
473        let res = a.insert(&df(1, 3, 1, 4, 10, vec![9, 10])).unwrap();
474        assert_eq!(res.payload.len(), 10);
475    }
476
477    #[test]
478    fn duplicate_fragment_is_idempotent() {
479        let mut a = FragmentAssembler::default();
480        assert!(a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4])).is_none());
481        assert!(a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4])).is_none());
482        assert_eq!(a.missing_fragments(SequenceNumber(1)).num_bits, 1);
483    }
484
485    #[test]
486    fn missing_fragments_enumerates_gaps() {
487        let mut a = FragmentAssembler::default();
488        // Fragment 2 is missing
489        assert!(a.insert(&df(1, 1, 1, 4, 10, vec![1, 2, 3, 4])).is_none());
490        assert!(a.insert(&df(1, 3, 1, 4, 10, vec![9, 10])).is_none());
491        let ms = a.missing_fragments(SequenceNumber(1));
492        let collected: Vec<_> = ms.iter_set().collect();
493        assert_eq!(collected, vec![FragmentNumber(2)]);
494    }
495
496    #[test]
497    fn inconsistent_sample_size_drops_fragment() {
498        let mut a = FragmentAssembler::default();
499        assert!(a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4])).is_none());
500        // Second fragment reports sample_size=12 instead of 8 → discarded
501        let res = a.insert(&df(1, 2, 1, 4, 12, vec![5, 6, 7, 8]));
502        assert!(res.is_none());
503        assert_eq!(a.drop_count(), 1);
504        // sn=1 is still in-flight with the old sample_size=8
505        assert_eq!(a.missing_fragments(SequenceNumber(1)).num_bits, 1);
506    }
507
508    #[test]
509    fn sample_too_large_drops_without_alloc() {
510        let caps = AssemblerCaps {
511            max_sample_bytes: 16,
512            ..AssemblerCaps::default()
513        };
514        let mut a = FragmentAssembler::new(caps);
515        // sample_size=100 > cap=16 → discarded
516        assert!(a.insert(&df(1, 1, 1, 4, 100, vec![1, 2, 3, 4])).is_none());
517        assert!(a.is_empty());
518        assert_eq!(a.drop_count(), 1);
519    }
520
521    #[test]
522    fn fragment_size_zero_dropped() {
523        let mut a = FragmentAssembler::default();
524        // frag_size=0 → avoid div by 0
525        assert!(a.insert(&df(1, 1, 1, 0, 4, vec![1, 2, 3, 4])).is_none());
526        assert_eq!(a.drop_count(), 1);
527    }
528
529    #[test]
530    fn fragment_index_zero_dropped() {
531        let mut a = FragmentAssembler::default();
532        assert!(a.insert(&df(1, 0, 1, 4, 4, vec![1, 2, 3, 4])).is_none());
533        assert_eq!(a.drop_count(), 1);
534    }
535
536    #[test]
537    fn fragment_index_out_of_range_dropped() {
538        let mut a = FragmentAssembler::default();
539        // sample_size=4, frag_size=4 → total=1, but index 2 requested
540        assert!(a.insert(&df(1, 2, 1, 4, 4, vec![0])).is_none());
541        assert_eq!(a.drop_count(), 1);
542    }
543
544    #[test]
545    fn payload_size_mismatch_dropped() {
546        let mut a = FragmentAssembler::default();
547        // frag_size=4 but payload is only 2 bytes → mismatch
548        assert!(a.insert(&df(1, 1, 1, 4, 8, vec![1, 2])).is_none());
549        assert_eq!(a.drop_count(), 1);
550    }
551
552    #[test]
553    fn max_pending_sns_evicts_oldest() {
554        let caps = AssemblerCaps {
555            max_pending_sns: 2,
556            ..AssemblerCaps::default()
557        };
558        let mut a = FragmentAssembler::new(caps);
559        // SN 1, 2 open (only fragment 1 of 2 received each)
560        a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4]));
561        a.insert(&df(2, 1, 1, 4, 8, vec![1, 2, 3, 4]));
562        assert_eq!(a.len(), 2);
563        // SN 3 pushes SN 1 out
564        a.insert(&df(3, 1, 1, 4, 8, vec![1, 2, 3, 4]));
565        assert_eq!(a.len(), 2);
566        assert!(a.buffers.contains_key(&SequenceNumber(2)));
567        assert!(a.buffers.contains_key(&SequenceNumber(3)));
568        assert_eq!(a.drop_count(), 1);
569    }
570
571    #[test]
572    fn has_gaps_flips_to_false_after_completion() {
573        let mut a = FragmentAssembler::default();
574        a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4]));
575        assert!(a.has_gaps());
576        a.insert(&df(1, 2, 1, 4, 8, vec![5, 6, 7, 8]));
577        assert!(!a.has_gaps());
578    }
579
580    #[test]
581    fn incomplete_sns_enumerates_in_order() {
582        let mut a = FragmentAssembler::default();
583        a.insert(&df(5, 1, 1, 4, 8, vec![1, 2, 3, 4]));
584        a.insert(&df(2, 1, 1, 4, 8, vec![1, 2, 3, 4]));
585        let sns: Vec<_> = a.incomplete_sns().collect();
586        assert_eq!(sns, vec![SequenceNumber(2), SequenceNumber(5)]);
587    }
588
589    #[test]
590    fn discard_removes_buffer() {
591        let mut a = FragmentAssembler::default();
592        a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4]));
593        assert!(a.discard(SequenceNumber(1)));
594        assert!(a.is_empty());
595        assert!(!a.discard(SequenceNumber(1)));
596    }
597
598    #[test]
599    fn missing_for_unknown_sn_is_empty() {
600        let a = FragmentAssembler::default();
601        assert_eq!(a.missing_fragments(SequenceNumber(42)).num_bits, 0);
602    }
603
604    // ---- fragments_in_submessage > 1 (bundle decode) ----
605
606    #[test]
607    fn bundled_fragments_all_full() {
608        // 3 fragments in one submessage, all full (no tail).
609        // sample_size=18, frag_size=4, total=5. We bundle fragments 1-3.
610        let mut a = FragmentAssembler::default();
611        let payload = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
612        let res = a.insert(&df(1, 1, 3, 4, 18, payload.clone()));
613        assert!(res.is_none(), "not yet complete");
614        // Fragments 4, 5 are still missing
615        let ms: Vec<_> = a.missing_fragments(SequenceNumber(1)).iter_set().collect();
616        assert_eq!(ms, vec![FragmentNumber(4), FragmentNumber(5)]);
617    }
618
619    #[test]
620    fn bundled_fragments_including_last_with_tail() {
621        // 2 fragments in one submessage, incl. the last (tail shortened).
622        // sample_size=10, frag_size=4, total=3. Bundle: fragments 2-3.
623        let mut a = FragmentAssembler::default();
624        // First present fragment 1
625        assert!(
626            a.insert(&df(1, 1, 1, 4, 10, vec![0xA, 0xB, 0xC, 0xD]))
627                .is_none()
628        );
629        // Now bundle 2+3 (4 + 2 bytes = 6)
630        let bundle = vec![5, 6, 7, 8, 9, 10];
631        let res = a.insert(&df(1, 2, 2, 4, 10, bundle));
632        assert!(res.is_some());
633        let s = res.unwrap();
634        assert_eq!(s.payload, vec![0xA, 0xB, 0xC, 0xD, 5, 6, 7, 8, 9, 10]);
635    }
636
637    #[test]
638    fn bundled_fragments_payload_size_mismatch_rejected() {
639        // Bundle with a claimed 3 fragments of 4 bytes = 12, but
640        // only 10 bytes delivered.
641        let mut a = FragmentAssembler::default();
642        let payload = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
643        assert!(a.insert(&df(1, 1, 3, 4, 20, payload)).is_none());
644        assert_eq!(a.drop_count(), 1);
645        assert_eq!(a.last_drop_reason(), Some(DropReason::PayloadSizeMismatch));
646    }
647
648    // ---- last_drop_reason diagnosis ----
649
650    #[test]
651    fn last_drop_reason_tracks_most_recent() {
652        let mut a = FragmentAssembler::default();
653        assert_eq!(a.last_drop_reason(), None);
654        a.insert(&df(1, 0, 1, 4, 4, vec![1, 2, 3, 4]));
655        assert_eq!(a.last_drop_reason(), Some(DropReason::FragmentIndexZero));
656        a.insert(&df(1, 1, 1, 0, 4, vec![1, 2, 3, 4]));
657        assert_eq!(a.last_drop_reason(), Some(DropReason::FragmentSizeInvalid));
658    }
659
660    #[test]
661    fn pending_sns_cap_exceeded_uses_dedicated_reason() {
662        let caps = AssemblerCaps {
663            max_pending_sns: 1,
664            ..AssemblerCaps::default()
665        };
666        let mut a = FragmentAssembler::new(caps);
667        a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4]));
668        a.insert(&df(2, 1, 1, 4, 8, vec![1, 2, 3, 4]));
669        assert_eq!(
670            a.last_drop_reason(),
671            Some(DropReason::PendingSnsCapExceeded)
672        );
673    }
674
675    #[test]
676    fn default_assembler_uses_default_caps() {
677        // B2 regression: the Default trait must return a working
678        // assembler (not just zero state, but correct caps).
679        let mut a = FragmentAssembler::default();
680        assert!(a.is_empty());
681        // Typical case: 1-fragment sample complete
682        let res = a.insert(&df(1, 1, 1, 4, 4, vec![1, 2, 3, 4]));
683        assert!(res.is_some());
684    }
685
686    #[test]
687    fn reset_diagnostics_clears_counters_but_keeps_buffers() {
688        // B8 regression: reset_diagnostics should only zero the metric
689        // state, in-flight buffers are kept.
690        let mut a = FragmentAssembler::default();
691        a.insert(&df(1, 0, 1, 4, 4, vec![1, 2, 3, 4])); // FragmentIndexZero → drop
692        a.insert(&df(2, 1, 1, 4, 8, vec![1, 2, 3, 4])); // partial buffer
693        assert_eq!(a.drop_count(), 1);
694        assert_eq!(a.len(), 1);
695        a.reset_diagnostics();
696        assert_eq!(a.drop_count(), 0);
697        assert!(a.last_drop_reason().is_none());
698        assert_eq!(a.len(), 1, "buffers must stay intact");
699    }
700
701    #[test]
702    fn max_pending_sns_zero_rejects_with_assembler_disabled() {
703        let caps = AssemblerCaps {
704            max_pending_sns: 0,
705            ..AssemblerCaps::default()
706        };
707        let mut a = FragmentAssembler::new(caps);
708        a.insert(&df(1, 1, 1, 4, 8, vec![1, 2, 3, 4]));
709        assert_eq!(a.last_drop_reason(), Some(DropReason::AssemblerDisabled));
710        assert!(a.is_empty());
711    }
712}