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