Skip to main content

dvb_si/collect/
mod.rs

1//! Multi-section table collection.
2//!
3//! Section parsers in [`crate::tables`] describe one wire section. This module
4//! adds the next layer up: collect all sections in `0..=last_section_number`
5//! for one logical version, then expose a complete table view.
6//!
7//! Collectors validate long-form section CRCs before retaining bytes. If the
8//! input already came from [`crate::demux::SiDemux`], that validation has
9//! already happened; direct section-byte callers get the same guard here.
10//!
11//! A collector error describes the section that was just pushed, not the whole
12//! stream. Long-running consumers should normally log/drop that section and
13//! continue feeding later sections; previous valid collector state is retained.
14
15use alloc::collections::BTreeMap;
16use alloc::sync::Arc;
17use alloc::vec;
18use alloc::vec::Vec;
19use mpeg_ts::pid::Pid;
20
21use crate::descriptors::{AnyDescriptor, DescriptorLoop, DescriptorRegistry};
22use broadcast_common::Parse;
23use mpeg_ts::section::Section;
24
25mod bat;
26mod eit;
27mod nit;
28mod sdt;
29
30pub use bat::*;
31pub use eit::*;
32pub use nit::*;
33pub use sdt::*;
34
35/// Default cap on the number of logical keys (in-progress AND completed)
36/// retained by [`SectionSetCollector`].
37///
38/// 256 concurrent collections is generous while bounding a hostile stream that
39/// rotates table_id / extension / current_next_indicator across PIDs to force
40/// unbounded map growth. The cap is applied to the partial-sections map
41/// (which also holds completed entries, so a repeat/duplicate section for a
42/// finished set is still recognized). When the map is full, incoming
43/// sections for a *new* key evict the least-recently-touched existing key
44/// (see [`SectionSetCollector`]'s type-level docs) rather than being
45/// silently dropped; [`clear`](SectionSetCollector::clear) still frees all
46/// capacity at once for an application-defined boundary.
47pub const DEFAULT_MAX_PARTIAL_KEYS: usize = 256;
48
49/// Result alias for collection operations.
50pub type CollectResult<T> = core::result::Result<T, CollectError>;
51
52/// Errors returned by multi-section collectors.
53///
54/// These errors are scoped to the current input section. They usually mean
55/// "skip this section and keep going", especially on live streams where a
56/// broadcaster may mutate section bytes without bumping `version_number`.
57#[derive(Debug, thiserror::Error)]
58#[non_exhaustive]
59pub enum CollectError {
60    /// The section bytes did not parse as a generic PSI/SI section.
61    #[error("section parse failed: {0}")]
62    Section(#[from] crate::Error),
63
64    /// A short-form section was fed to a multi-section collector.
65    #[error(
66        "table_id {table_id:#04x} is a short-form section and cannot be multi-section collected"
67    )]
68    ShortFormSection {
69        /// Raw table_id byte.
70        table_id: u8,
71    },
72
73    /// `section_number` was outside the advertised section range.
74    #[error(
75        "section_number {section_number} exceeds last_section_number {last_section_number} for table_id {table_id:#04x}"
76    )]
77    SectionNumberOutOfRange {
78        /// Raw table_id byte.
79        table_id: u8,
80        /// Section number carried by the section.
81        section_number: u8,
82        /// Last section number carried by the section.
83        last_section_number: u8,
84    },
85
86    /// A slot already contained different bytes for the same version.
87    #[error("conflicting bytes for table_id {table_id:#04x} section {section_number}")]
88    ConflictingSection {
89        /// Raw table_id byte.
90        table_id: u8,
91        /// Section slot that conflicted.
92        section_number: u8,
93    },
94
95    /// An EIT schedule section advertised an impossible table-id range.
96    #[error(
97        "EIT schedule table_id {table_id:#04x} is outside advertised range {first_table_id:#04x}..={last_table_id:#04x}"
98    )]
99    EitTableIdOutOfRange {
100        /// Incoming EIT schedule table_id.
101        table_id: u8,
102        /// First table_id for this schedule kind.
103        first_table_id: u8,
104        /// Advertised last_table_id.
105        last_table_id: u8,
106    },
107}
108
109impl From<mpeg_ts::Error> for CollectError {
110    fn from(e: mpeg_ts::Error) -> Self {
111        CollectError::Section(crate::Error::from(e))
112    }
113}
114
115/// Logical key for one section sequence.
116///
117/// The key deliberately excludes `version_number` and `section_number`. Version
118/// changes reset a collection; section numbers index into that collection.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
120#[non_exhaustive]
121pub struct SectionSetKey {
122    /// Optional PID context supplied by the caller.
123    pub pid: Option<Pid>,
124    /// Raw `table_id`.
125    pub table_id: u8,
126    /// Long-form `table_id_extension`.
127    pub extension_id: u16,
128    /// `current_next_indicator`.
129    pub current_next_indicator: bool,
130}
131
132/// Metadata shared by every section in a complete section set.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub struct SectionSetMeta {
136    /// Logical section-set key.
137    pub key: SectionSetKey,
138    /// 5-bit `version_number`.
139    pub version_number: u8,
140    /// Last section number for this set.
141    pub last_section_number: u8,
142}
143
144#[derive(Debug)]
145struct PartialSectionSet {
146    meta: SectionSetMeta,
147    slots: Vec<Option<Arc<[u8]>>>,
148    filled: usize,
149    emitted: bool,
150    /// The collector's `tick` at the last touch (push) of this key; `0` is a
151    /// sentinel meaning "never touched yet" (the collector's first real tick
152    /// is `1`), used to find and evict the least-recently-touched entry.
153    last_touch: u64,
154}
155
156impl PartialSectionSet {
157    fn new(meta: SectionSetMeta) -> Self {
158        let len = meta.last_section_number as usize + 1;
159        Self {
160            meta,
161            slots: vec![None; len],
162            filled: 0,
163            emitted: false,
164            last_touch: 0,
165        }
166    }
167
168    fn reset(&mut self, meta: SectionSetMeta) {
169        let last_touch = self.last_touch;
170        *self = Self::new(meta);
171        self.last_touch = last_touch;
172    }
173
174    fn insert(&mut self, section_number: u8, bytes: &[u8]) -> CollectResult<bool> {
175        let index = section_number as usize;
176        if let Some(existing) = &self.slots[index] {
177            if existing.as_ref() == bytes {
178                return Ok(false);
179            }
180            return Err(CollectError::ConflictingSection {
181                table_id: self.meta.key.table_id,
182                section_number,
183            });
184        }
185
186        // Allocate only for a section that is actually retained: a repeating
187        // carousel re-sends identical sections every cycle, and copying each
188        // one just to compare and drop it was pure waste (audit r02-O1).
189        self.slots[index] = Some(Arc::from(bytes));
190        self.filled += 1;
191        self.emitted = false;
192        Ok(true)
193    }
194
195    fn complete(&self) -> bool {
196        self.filled == self.slots.len()
197    }
198
199    fn to_complete(&self) -> Option<CompleteSectionSet> {
200        if !self.complete() || self.emitted {
201            return None;
202        }
203
204        let sections = self
205            .slots
206            .iter()
207            .map(|slot| slot.as_ref().expect("complete set has no holes").clone())
208            .collect();
209        Some(CompleteSectionSet {
210            meta: self.meta,
211            sections,
212        })
213    }
214}
215
216/// Generic collector for long-form `section_number`/`last_section_number`
217/// sequences.
218///
219/// The constructor [`SectionSetCollector::new`] uses the default cap
220/// [`DEFAULT_MAX_PARTIAL_KEYS`]; the cap is configurable via
221/// [`with_max_partial_keys`](Self::with_max_partial_keys).
222///
223/// # Capacity: LRU eviction, not a permanent wall (issue #1002)
224///
225/// The cap bounds `partial`'s total entry count, including **completed**
226/// sets — a completed set is retained (so a repeat/duplicate section for it
227/// is recognized and does not re-emit) but a real network can complete far
228/// more distinct `(table_id, extension_id, current_next_indicator)` keys
229/// over time than the cap allows to stay in-progress simultaneously (EIT
230/// *other* alone can span 150+ services). Capping only entry *creation* and
231/// never evicting anything would eventually wall off every new key behind a
232/// permanently-full map of old, no-longer-interesting completed entries,
233/// with the caller seeing only silent `Ok(None)` and no way to tell "still
234/// collecting" apart from "capacity exhausted, this key will never
235/// complete". Instead, when a *new* key arrives at capacity, the
236/// **least-recently-touched** entry (by push, not by construction) is
237/// evicted to admit it — old, no-longer-fed keys naturally age out in
238/// favor of active ones. [`evicted_for_capacity`](Self::evicted_for_capacity)
239/// counts evictions, so a caller can detect this churn instead of it being
240/// invisible.
241#[derive(Debug)]
242pub struct SectionSetCollector {
243    partial: BTreeMap<SectionSetKey, PartialSectionSet>,
244    /// `tick` at last touch → key, for O(log n) least-recently-touched lookup.
245    touch_order: BTreeMap<u64, SectionSetKey>,
246    tick: u64,
247    max_partial_keys: usize,
248    evicted_for_capacity: u64,
249}
250
251impl Default for SectionSetCollector {
252    fn default() -> Self {
253        Self {
254            partial: BTreeMap::new(),
255            touch_order: BTreeMap::new(),
256            tick: 0,
257            max_partial_keys: DEFAULT_MAX_PARTIAL_KEYS,
258            evicted_for_capacity: 0,
259        }
260    }
261}
262
263impl SectionSetCollector {
264    /// Create an empty collector with the default cap
265    /// ([`DEFAULT_MAX_PARTIAL_KEYS`]).
266    #[must_use]
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Replace the partial-key cap (default [`DEFAULT_MAX_PARTIAL_KEYS`]).
272    /// When a *new* key arrives at capacity, the least-recently-touched
273    /// existing key is evicted to admit it (see the type-level docs).
274    #[must_use]
275    pub fn with_max_partial_keys(mut self, max_partial_keys: usize) -> Self {
276        self.max_partial_keys = max_partial_keys;
277        self
278    }
279
280    /// Number of keys evicted so far to admit a new key at capacity. A
281    /// nonzero, growing count means the cap is genuinely too small for the
282    /// stream's distinct-key churn — raise it via
283    /// [`with_max_partial_keys`](Self::with_max_partial_keys).
284    #[must_use]
285    pub fn evicted_for_capacity(&self) -> u64 {
286        self.evicted_for_capacity
287    }
288
289    /// Record a touch of `key` at the current tick, evicting the
290    /// least-recently-touched entry first if `key` is new and the map is
291    /// already at capacity. Returns `false` only in the (should-be
292    /// unreachable) case that eviction could not free a slot.
293    fn touch_or_evict(&mut self, key: SectionSetKey) -> bool {
294        self.tick = self.tick.wrapping_add(1).max(1);
295        if !self.partial.contains_key(&key) && self.partial.len() >= self.max_partial_keys {
296            let oldest = self.touch_order.keys().next().copied();
297            match oldest {
298                Some(oldest_tick) => {
299                    if let Some(oldest_key) = self.touch_order.remove(&oldest_tick) {
300                        self.partial.remove(&oldest_key);
301                        self.evicted_for_capacity += 1;
302                    }
303                }
304                None => return false,
305            }
306        }
307        true
308    }
309
310    /// Push one complete section. Returns `Some` only when the logical section
311    /// set has become complete for the first time at this version.
312    ///
313    /// # Errors
314    ///
315    /// Returns a [`CollectError`] if the bytes are not a valid long-form
316    /// section or if the section set becomes internally inconsistent. Treat the
317    /// error as applying to this section only unless your application wants
318    /// strict stream-fail behavior.
319    pub fn push_section(
320        &mut self,
321        bytes: impl AsRef<[u8]>,
322    ) -> CollectResult<Option<CompleteSectionSet>> {
323        self.push_section_with_pid(None, bytes)
324    }
325
326    /// Push one complete section with PID context.
327    ///
328    /// The PID is folded into the section-set key so tables with identical
329    /// table id/extension on different PIDs do not collide.
330    pub fn push_section_with_pid(
331        &mut self,
332        pid: Option<Pid>,
333        bytes: impl AsRef<[u8]>,
334    ) -> CollectResult<Option<CompleteSectionSet>> {
335        let raw = bytes.as_ref();
336        let section = Section::parse(raw)?;
337        if !section.section_syntax_indicator {
338            return Err(CollectError::ShortFormSection {
339                table_id: section.table_id,
340            });
341        }
342        if section.section_number > section.last_section_number {
343            return Err(CollectError::SectionNumberOutOfRange {
344                table_id: section.table_id,
345                section_number: section.section_number,
346                last_section_number: section.last_section_number,
347            });
348        }
349        section.validate_crc(raw)?;
350
351        let key = SectionSetKey {
352            pid,
353            table_id: section.table_id,
354            extension_id: section.extension_id,
355            current_next_indicator: section.current_next_indicator,
356        };
357        let meta = SectionSetMeta {
358            key,
359            version_number: section.version_number,
360            last_section_number: section.last_section_number,
361        };
362        // Cap check: evict the least-recently-touched key if `key` is new
363        // and the map is already full (see the type-level docs).
364        if !self.touch_or_evict(key) {
365            return Ok(None);
366        }
367        let now = self.tick;
368
369        let partial = self
370            .partial
371            .entry(key)
372            .or_insert_with(|| PartialSectionSet::new(meta));
373
374        if partial.meta.version_number != meta.version_number
375            || partial.meta.last_section_number != meta.last_section_number
376        {
377            partial.reset(meta);
378        }
379
380        // Re-touch: drop this key's old tick from touch_order (if any) before
381        // recording the new one, so touch_order never accumulates stale ticks
382        // for a key that keeps getting fed.
383        self.touch_order.remove(&partial.last_touch);
384        partial.last_touch = now;
385        self.touch_order.insert(now, key);
386
387        partial.insert(section.section_number, raw)?;
388        let complete = partial.to_complete();
389        if complete.is_some() {
390            partial.emitted = true;
391        }
392        Ok(complete)
393    }
394
395    /// Drop all retained partial section sets.
396    pub fn clear(&mut self) {
397        self.partial.clear();
398        self.touch_order.clear();
399    }
400
401    /// Number of retained partial section-set states.
402    #[must_use]
403    pub fn len(&self) -> usize {
404        self.partial.len()
405    }
406
407    /// Whether the collector currently has no retained state.
408    #[must_use]
409    pub fn is_empty(&self) -> bool {
410        self.partial.is_empty()
411    }
412}
413
414/// A complete owned set of original section bytes for one logical section
415/// sequence.
416#[derive(Debug, Clone)]
417pub struct CompleteSectionSet {
418    meta: SectionSetMeta,
419    sections: Vec<Arc<[u8]>>,
420}
421
422/// Generic complete table view for one collected section set.
423///
424/// This is the all-table escape hatch: every long-form PSI/SI table with
425/// `section_number`/`last_section_number` can be collected into a
426/// [`CompleteSectionSet`] and parsed as `CompleteTable<T>`. Table-specific
427/// complete views such as [`CompleteNit`] add flattened convenience fields where
428/// the logical table shape is useful.
429#[derive(Debug)]
430pub struct CompleteTable<T> {
431    meta: SectionSetMeta,
432    sections: Vec<T>,
433}
434
435impl<T> CompleteTable<T> {
436    /// Metadata shared by the section set.
437    #[must_use]
438    pub const fn meta(&self) -> SectionSetMeta {
439        self.meta
440    }
441
442    /// Parsed sections in section-number order.
443    #[must_use]
444    pub fn sections(&self) -> &[T] {
445        &self.sections
446    }
447
448    /// Consume the complete table and return the parsed sections.
449    #[must_use]
450    pub fn into_sections(self) -> Vec<T> {
451        self.sections
452    }
453}
454
455impl CompleteSectionSet {
456    /// Metadata shared by the section set.
457    #[must_use]
458    pub const fn meta(&self) -> SectionSetMeta {
459        self.meta
460    }
461
462    /// Complete section bytes in section-number order.
463    #[must_use]
464    pub fn section_bytes(&self) -> impl ExactSizeIterator<Item = &[u8]> {
465        self.sections.iter().map(AsRef::as_ref)
466    }
467
468    /// Parse every section in this set as `T`.
469    ///
470    /// The parsed values borrow from this [`CompleteSectionSet`], so callers can
471    /// retain the set and use borrowed typed views without copying table loops.
472    pub fn parse_sections<'a, T>(&'a self) -> crate::Result<Vec<T>>
473    where
474        T: Parse<'a, Error = crate::Error>,
475    {
476        self.section_bytes().map(T::parse).collect()
477    }
478
479    /// Parse this set as a generic complete table.
480    ///
481    /// Use this for any long-form table that does not need a specialised
482    /// flattened logical view.
483    pub fn table<'a, T>(&'a self) -> crate::Result<CompleteTable<T>>
484    where
485        T: Parse<'a, Error = crate::Error>,
486    {
487        Ok(CompleteTable {
488            meta: self.meta,
489            sections: self.parse_sections()?,
490        })
491    }
492
493    /// Build a complete NIT view from this section set.
494    pub fn nit(&self) -> crate::Result<CompleteNit<'_>> {
495        CompleteNit::parse(self, None)
496    }
497
498    /// Build a complete NIT view using a descriptor registry.
499    pub fn nit_with_registry<'a>(
500        &'a self,
501        registry: &'a DescriptorRegistry,
502    ) -> crate::Result<CompleteNit<'a>> {
503        CompleteNit::parse(self, Some(registry))
504    }
505
506    /// Build a complete BAT view from this section set.
507    pub fn bat(&self) -> crate::Result<CompleteBat<'_>> {
508        CompleteBat::parse(self, None)
509    }
510
511    /// Build a complete BAT view using a descriptor registry.
512    pub fn bat_with_registry<'a>(
513        &'a self,
514        registry: &'a DescriptorRegistry,
515    ) -> crate::Result<CompleteBat<'a>> {
516        CompleteBat::parse(self, Some(registry))
517    }
518
519    /// Build a complete SDT view from this section set.
520    pub fn sdt(&self) -> crate::Result<CompleteSdt<'_>> {
521        CompleteSdt::parse(self, None)
522    }
523
524    /// Build a complete SDT view using a descriptor registry.
525    pub fn sdt_with_registry<'a>(
526        &'a self,
527        registry: &'a DescriptorRegistry,
528    ) -> crate::Result<CompleteSdt<'a>> {
529        CompleteSdt::parse(self, Some(registry))
530    }
531
532    /// Build a complete EIT view from this section set.
533    pub fn eit(&self) -> crate::Result<CompleteEit<'_>> {
534        CompleteEit::parse(self, None)
535    }
536
537    /// Build a complete EIT view using a descriptor registry.
538    pub fn eit_with_registry<'a>(
539        &'a self,
540        registry: &'a DescriptorRegistry,
541    ) -> crate::Result<CompleteEit<'a>> {
542        CompleteEit::parse(self, Some(registry))
543    }
544}
545
546/// Parsed descriptor loop retaining the raw bytes and the typed descriptor
547/// results.
548///
549/// Usually backed by a single section's descriptor loop, but a table-wide
550/// loop that a spec allows to split across sections (e.g. NIT §5.2.1 / BAT
551/// §5.2.2's `network_descriptors_loop` / `bouquet_descriptors_loop`, one per
552/// section) is built by concatenating each section's loop internally:
553/// `raw()` then yields one entry per contributing section, in section
554/// order, and `descriptors()` is their concatenation in the same order.
555#[derive(Debug)]
556pub struct ParsedDescriptorLoop<'a> {
557    raw: Vec<DescriptorLoop<'a>>,
558    descriptors: Vec<crate::Result<AnyDescriptor<'a>>>,
559}
560
561impl<'a> ParsedDescriptorLoop<'a> {
562    pub(crate) fn parse(raw: DescriptorLoop<'a>, registry: Option<&'a DescriptorRegistry>) -> Self {
563        Self::parse_all(core::iter::once(raw), registry)
564    }
565
566    /// Parse and concatenate descriptors from several raw descriptor loops,
567    /// in order, into one logical loop.
568    pub(crate) fn parse_all(
569        raws: impl IntoIterator<Item = DescriptorLoop<'a>>,
570        registry: Option<&'a DescriptorRegistry>,
571    ) -> Self {
572        let raw: Vec<DescriptorLoop<'a>> = raws.into_iter().collect();
573        let mut descriptors = Vec::new();
574        for loop_ in &raw {
575            match registry {
576                Some(registry) => descriptors.extend(registry.parse_loop(loop_.raw())),
577                None => descriptors.extend(loop_.iter()),
578            }
579        }
580        Self { raw, descriptors }
581    }
582
583    /// Raw descriptor-loop bytes, one entry per contributing section (in
584    /// section order). Almost always a single entry.
585    ///
586    /// Use `raw()[i].iter_with_extensions(&desc_reg, &ext_reg)` to recover
587    /// custom extension bodies from a `Complete*` view.
588    #[must_use]
589    pub fn raw(&self) -> &[DescriptorLoop<'a>] {
590        &self.raw
591    }
592
593    /// Typed descriptor parse results in wire order (concatenated across
594    /// every contributing section for a multi-section loop).
595    pub fn descriptors(&self) -> &[crate::Result<AnyDescriptor<'a>>] {
596        &self.descriptors
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    const TEST_TABLE_ID: u8 = 0x42;
605
606    fn min_section(extension_id: u16) -> Vec<u8> {
607        let section_length: u16 = 9; // 5 (ext_header) + 0 (payload) + 4 (crc)
608        let mut buf = vec![0u8; 12];
609        buf[0] = TEST_TABLE_ID;
610        buf[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
611        buf[2] = (section_length & 0xFF) as u8;
612        buf[3..5].copy_from_slice(&extension_id.to_be_bytes());
613        buf[5] = 0xC1;
614        buf[6] = 0;
615        buf[7] = 0;
616        let crc = broadcast_common::crc32_mpeg2::compute(&buf[..8]);
617        buf[8..12].copy_from_slice(&crc.to_be_bytes());
618        buf
619    }
620
621    #[test]
622    fn collect_single_section_is_complete() {
623        let mut c = SectionSetCollector::new();
624        let sec = min_section(0);
625        let result = c.push_section(&sec).unwrap();
626        assert!(result.is_some());
627        assert_eq!(c.len(), 1);
628    }
629
630    /// Regression for issue #1002: a completed (`emitted = true`) entry used
631    /// to count against the cap forever, so once the map filled with
632    /// completed keys, every later distinct key silently got `Ok(None)` with
633    /// no way to tell "still collecting" from "capacity exhausted, this will
634    /// never complete". A new key at capacity must now evict the
635    /// least-recently-touched entry (even a completed one) and succeed.
636    #[test]
637    fn new_key_beyond_cap_evicts_least_recently_touched_completed_entry() {
638        let mut c = SectionSetCollector::new().with_max_partial_keys(3);
639
640        // Push sections for 3 distinct extension IDs — fills the cap, all completed.
641        for eid in 0..3u16 {
642            let sec = min_section(eid);
643            let result = c.push_section(&sec).unwrap();
644            assert!(
645                result.is_some(),
646                "single-section set for eid {eid} completes"
647            );
648        }
649        assert_eq!(c.len(), 3);
650        assert_eq!(c.evicted_for_capacity(), 0);
651
652        // A 4th distinct key now EVICTS the least-recently-touched (eid 0),
653        // not silently vanishes into Ok(None).
654        let sec4 = min_section(3);
655        let result = c.push_section(&sec4).unwrap();
656        assert!(
657            result.is_some(),
658            "new key beyond cap must evict, not be skipped"
659        );
660        assert_eq!(c.len(), 3, "cap stays honored, not just grown");
661        assert_eq!(c.evicted_for_capacity(), 1);
662
663        // eid 0 is gone; feeding it again is treated as a brand-new key
664        // (re-completes from scratch) rather than "already known".
665        let sec0_again = min_section(0);
666        let result = c.push_section(&sec0_again).unwrap();
667        assert!(result.is_some(), "evicted key re-admits like a fresh one");
668
669        // Clear frees space too.
670        c.clear();
671        assert!(c.is_empty());
672        let result = c.push_section(&sec4).unwrap();
673        assert!(result.is_some());
674        assert_eq!(c.len(), 1);
675    }
676
677    #[test]
678    fn partial_keys_cap_does_not_skip_existing_key() {
679        let mut c = SectionSetCollector::new().with_max_partial_keys(1);
680
681        // Fill the cap with one multi-section NIT-like extension (section 0 of 1).
682        let sec0 = {
683            let mut buf = min_section(0xAB);
684            // Make section 0 of 1 be incomplete: change last_section_number to 1
685            buf[7] = 1;
686            // Recompute CRC
687            let crc = broadcast_common::crc32_mpeg2::compute(&buf[..8]);
688            buf[8..12].copy_from_slice(&crc.to_be_bytes());
689            buf
690        };
691        let result = c.push_section(&sec0).unwrap();
692        assert!(result.is_none(), "incomplete section set yields None");
693
694        // Push section 1 of 1 for the same key — cap is full but key already
695        // exists, so it must NOT be skipped.
696        let mut sec1 = min_section(0xAB);
697        sec1[6] = 1; // section_number = 1
698        sec1[7] = 1; // last_section_number = 1
699        let crc = broadcast_common::crc32_mpeg2::compute(&sec1[..8]);
700        sec1[8..12].copy_from_slice(&crc.to_be_bytes());
701
702        let result = c.push_section(&sec1).unwrap();
703        assert!(
704            result.is_some(),
705            "existing key must NOT be skipped when cap full"
706        );
707        assert_eq!(c.len(), 1);
708    }
709}