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}