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}