Skip to main content

mpeg_ts/
section.rs

1//! Generic PSI/SI section framing — ITU-T H.222.0 §2.4 (= ISO/IEC 13818-1) and ETSI EN 300 468 §5.1.1.
2//!
3//! Every PSI and SI table is carried in one or more sections. This module
4//! parses the **section header** and exposes the payload + CRC for
5//! table-specific parsers to consume.
6//!
7//! # Section layout
8//!
9//! ```text
10//! byte 0:       table_id (8 bits)
11//! byte 1 bit 7: section_syntax_indicator (1 bit)
12//! byte 1 bit 6: private_indicator (1 bit)
13//! byte 1 bits 5-4: reserved (2 bits — ignored)
14//! byte 1 bits 3-0 + byte 2: section_length (12 bits)
15//!
16//! Long-form (section_syntax_indicator == 1):
17//!   byte 3-4:   table_id_extension (16 bits)
18//!   byte 5:     reserved(2) | version_number(5) | current_next_indicator(1)
19//!   byte 6:     section_number (8 bits)
20//!   byte 7:     last_section_number (8 bits)
21//!   byte 8..(total-4): payload
22//!   last 4 bytes: CRC_32
23//!
24//! Short-form (section_syntax_indicator == 0, e.g. TDT):
25//!   byte 3..(3+section_length): payload (no extension header, no CRC)
26//! ```
27//!
28//! NOTE the TOT exception: the TOT (0x73) also sets SSI=0 but DOES end with a
29//! CRC_32 (EN 300 468 §5.2.6). Parsing it through this generic short-form
30//! path folds the CRC into `payload` — use the table-specific `TotSection` parser instead.
31//!
32//! `section_length` counts bytes *after* the 3-byte section header, so the
33//! total section size is `section_length + 3`.
34
35use crate::error::{Error, Result};
36use broadcast_common::crc32_mpeg2 as crc;
37use broadcast_common::{Parse, Serialize};
38
39// Minimum bytes to read the section header (table_id + section_syntax_indicator
40// + section_length field = 3 bytes).
41const MIN_HEADER_LEN: usize = 3;
42
43// Long-form header adds: extension_id(2) + version/cni(1) + sec_num(1) +
44// last_sec_num(1) = 5 bytes.
45const LONG_FORM_EXTRA: usize = 5;
46
47// CRC occupies the last 4 bytes of every long-form section.
48const CRC_LEN: usize = 4;
49
50// W15 (#1074): named bit-field masks for the section header, used by both
51// `parse` and `serialize_into` — ISO/IEC 13818-1 §2.4.4.1.
52
53/// `section_syntax_indicator` — byte 1 bit 7.
54const SSI_MASK: u8 = 0x80;
55/// `private_indicator` (byte 1 bit 6). Meaning is table-specific; DVB SI uses
56/// this position for the DVB `private_indicator` (ETSI EN 300 468 §5.1.1).
57const PRIVATE_INDICATOR_MASK: u8 = 0x40;
58/// Byte 1 bits 5-4: reserved, convention is both set on serialize.
59const BYTE1_RESERVED_BITS: u8 = 0x30;
60/// `version_number` — 5 bits (byte 5 bits 5-1 of the long-form extension header).
61const VERSION_NUMBER_MASK: u8 = 0x1F;
62/// Byte 5 bits 7-6: reserved, convention is both set on serialize.
63const BYTE5_RESERVED_BITS: u8 = 0xC0;
64/// `current_next_indicator` — byte 5 bit 0 of the long-form extension header.
65const CURRENT_NEXT_MASK: u8 = 0x01;
66
67/// A parsed PSI/SI section header, borrowing the raw input buffer for payload.
68///
69/// Created via `Section::parse(bytes)`. Does **not** validate the CRC on
70/// construction — call [`Section::validate_crc`] explicitly.
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize))]
73pub struct Section<'a> {
74    /// Table identifier.
75    pub table_id: u8,
76    /// When `true` the section uses the long-form syntax (has extension header
77    /// and CRC). When `false` only the 3-byte header is present (short form,
78    /// e.g. TDT — but see the module docs for the TOT exception: SSI=0 yet
79    /// CRC present; parse TOT via `tables::tot`, not this path).
80    pub section_syntax_indicator: bool,
81    /// Private indicator bit (meaning is table-specific).
82    pub private_indicator: bool,
83    /// Number of bytes following byte 2 of the section header.
84    pub section_length: u16,
85    /// Table ID extension (aka `table_id_extension`). Present only for
86    /// long-form sections; zero for short-form.
87    pub extension_id: u16,
88    /// Version number (5 bits). Present only for long-form sections.
89    pub version_number: u8,
90    /// `current_next_indicator` flag. Present only for long-form sections.
91    pub current_next_indicator: bool,
92    /// Section number within the table sub-table.
93    pub section_number: u8,
94    /// Number of the last section in the table sub-table.
95    pub last_section_number: u8,
96    /// Section payload: excludes the header bytes and the trailing CRC for
97    /// long-form sections. For short-form sections this is bytes
98    /// `3..(section_length + 3)`.
99    pub payload: &'a [u8],
100    /// Declared CRC value (last 4 bytes, big-endian). `None` for short-form
101    /// sections which carry no CRC.
102    pub crc32: Option<u32>,
103}
104
105impl<'a> Section<'a> {
106    /// Return the payload slice (same as the `payload` field — convenience
107    /// getter for code that has a `&Section` reference).
108    #[inline]
109    pub fn payload(&self) -> &'a [u8] {
110        self.payload
111    }
112
113    /// Validate the CRC-32 of the section against `raw` — the complete section
114    /// bytes (including header and CRC suffix).
115    ///
116    /// For short-form sections (`section_syntax_indicator == false`) this
117    /// returns `Ok(())` immediately because no CRC is present.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`Error::CrcMismatch`] when the computed CRC over
122    /// `raw[..raw.len() - 4]` does not match the declared value at
123    /// `raw[raw.len()-4..]`.
124    pub fn validate_crc(&self, raw: &[u8]) -> Result<()> {
125        let expected = match self.crc32 {
126            None => return Ok(()), // short-form — no CRC
127            Some(v) => v,
128        };
129
130        // Guard: raw must be at least CRC_LEN bytes for a valid long-form section.
131        if raw.len() < CRC_LEN {
132            return Err(Error::BufferTooShort {
133                need: CRC_LEN,
134                have: raw.len(),
135                what: "CRC suffix in validate_crc",
136            });
137        }
138
139        // The CRC covers everything up to (but not including) the 4 CRC bytes.
140        let covered = &raw[..raw.len() - CRC_LEN];
141        let computed = crc::compute(covered);
142
143        if computed != expected {
144            return Err(Error::CrcMismatch { computed, expected });
145        }
146        Ok(())
147    }
148}
149
150impl<'a> Parse<'a> for Section<'a> {
151    type Error = crate::error::Error;
152    /// Parse a complete section from `bytes`.
153    ///
154    /// # Errors
155    ///
156    /// - [`Error::BufferTooShort`] — fewer than 3 bytes supplied.
157    /// - [`Error::SectionLengthOverflow`] — `section_length` field declares
158    ///   more data than `bytes` contains.
159    fn parse(bytes: &'a [u8]) -> Result<Self> {
160        // ── 3-byte common header ────────────────────────────────────────────
161        if bytes.len() < MIN_HEADER_LEN {
162            return Err(Error::BufferTooShort {
163                need: MIN_HEADER_LEN,
164                have: bytes.len(),
165                what: "section header",
166            });
167        }
168
169        let table_id = bytes[0];
170        let section_syntax_indicator = (bytes[1] & SSI_MASK) != 0;
171        let private_indicator = (bytes[1] & PRIVATE_INDICATOR_MASK) != 0;
172
173        // Total section size is section_length + 3 (the 3-byte header itself
174        // is not counted by section_length). W14 (#1074): shared with the
175        // other three sites that used to decode this field separately.
176        let total = crate::ts::section_total_len(bytes)
177            .expect("bytes.len() >= MIN_HEADER_LEN checked above");
178        let section_length = (total - MIN_HEADER_LEN) as u16;
179
180        if bytes.len() < total {
181            return Err(Error::SectionLengthOverflow {
182                declared: total,
183                available: bytes.len(),
184            });
185        }
186
187        // Work only inside the declared section boundary.
188        let section_bytes = &bytes[..total];
189
190        if !section_syntax_indicator {
191            // ── Short-form section (e.g. TDT) ──────────────────────────────
192            // No extension header, no CRC. Payload is everything after the
193            // 3-byte header.
194            let payload = &section_bytes[MIN_HEADER_LEN..];
195            return Ok(Section {
196                table_id,
197                section_syntax_indicator,
198                private_indicator,
199                section_length,
200                extension_id: 0,
201                version_number: 0,
202                current_next_indicator: false,
203                section_number: 0,
204                last_section_number: 0,
205                payload,
206                crc32: None,
207            });
208        }
209
210        // ── Long-form section ───────────────────────────────────────────────
211        // Minimum size for a valid long-form section:
212        //   3 (common header) + 5 (extension header) + 4 (CRC) = 12 bytes.
213        let min_long = MIN_HEADER_LEN + LONG_FORM_EXTRA + CRC_LEN;
214        if section_bytes.len() < min_long {
215            return Err(Error::BufferTooShort {
216                need: min_long,
217                have: section_bytes.len(),
218                what: "long-form section extension header + CRC",
219            });
220        }
221
222        let extension_id = ((bytes[3] as u16) << 8) | (bytes[4] as u16);
223        let version_number = (bytes[5] >> 1) & VERSION_NUMBER_MASK;
224        let current_next_indicator = (bytes[5] & CURRENT_NEXT_MASK) != 0;
225        let section_number = bytes[6];
226        let last_section_number = bytes[7];
227
228        // Payload: bytes[8 .. total-4]  (excludes 5-byte extension header
229        // counting from offset 3, and the 4-byte CRC at the end).
230        let payload_start = MIN_HEADER_LEN + LONG_FORM_EXTRA;
231        let payload_end = total - CRC_LEN;
232        let payload = &section_bytes[payload_start..payload_end];
233
234        // Read declared CRC from last 4 bytes of the section (big-endian).
235        let crc_offset = total - CRC_LEN;
236        let crc32 = Some(
237            ((section_bytes[crc_offset] as u32) << 24)
238                | ((section_bytes[crc_offset + 1] as u32) << 16)
239                | ((section_bytes[crc_offset + 2] as u32) << 8)
240                | (section_bytes[crc_offset + 3] as u32),
241        );
242
243        Ok(Section {
244            table_id,
245            section_syntax_indicator,
246            private_indicator,
247            section_length,
248            extension_id,
249            version_number,
250            current_next_indicator,
251            section_number,
252            last_section_number,
253            payload,
254            crc32,
255        })
256    }
257}
258
259impl Serialize for Section<'_> {
260    type Error = crate::error::Error;
261    fn serialized_len(&self) -> usize {
262        // Total size = section_length + 3 (the 3-byte base header precedes
263        // the bytes counted by section_length).
264        usize::from(self.section_length) + MIN_HEADER_LEN
265    }
266
267    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
268        let need = self.serialized_len();
269        if buf.len() < need {
270            return Err(Error::OutputBufferTooSmall {
271                need,
272                have: buf.len(),
273            });
274        }
275
276        // W5 (#1074): `section_length` and `payload.len()` are two
277        // independently-settable public fields. If a caller edits one
278        // without the other, the old code indexed `buf` by
279        // `payload.len()` while `serialized_len()` (and thus `need`/`buf`)
280        // was sized from `section_length` — an over-long payload panicked
281        // on the `copy_from_slice` below, and an under-long one silently
282        // wrote a wrong CRC into stale trailing bytes. Validate the two
283        // agree before touching `buf`.
284        let overhead = if self.section_syntax_indicator {
285            LONG_FORM_EXTRA + CRC_LEN
286        } else {
287            0
288        };
289        let expected_payload_len = usize::from(self.section_length).checked_sub(overhead);
290        if expected_payload_len != Some(self.payload.len()) {
291            return Err(Error::SectionPayloadLengthMismatch {
292                section_length: self.section_length,
293                expected: expected_payload_len.unwrap_or(0),
294                actual: self.payload.len(),
295            });
296        }
297
298        // Byte 0: table_id
299        buf[0] = self.table_id;
300
301        // Byte 1: SSI | PI | 2-bit reserved (set high per spec) | length hi 4 bits
302        let length_hi = ((self.section_length >> 8) as u8) & crate::ts::SECTION_LENGTH_HI_MASK;
303        let ssi = u8::from(self.section_syntax_indicator) << 7;
304        let pi = u8::from(self.private_indicator) << 6;
305        // Reserved bits 5..4 are 'reserved' per §5.1.1 — convention is both set.
306        buf[1] = ssi | pi | BYTE1_RESERVED_BITS | length_hi;
307
308        // Byte 2: length low 8 bits
309        buf[2] = self.section_length.to_be_bytes()[1];
310
311        if self.section_syntax_indicator {
312            // Long form: 5 bytes of extension header, then payload, then CRC.
313            buf[3..5].copy_from_slice(&self.extension_id.to_be_bytes());
314            // Byte 5: 2-bit reserved (both high) | 5-bit version | 1-bit current_next
315            let version = (self.version_number & VERSION_NUMBER_MASK) << 1;
316            let cni = u8::from(self.current_next_indicator);
317            buf[5] = BYTE5_RESERVED_BITS | version | cni;
318            buf[6] = self.section_number;
319            buf[7] = self.last_section_number;
320
321            let payload_start = MIN_HEADER_LEN + LONG_FORM_EXTRA;
322            let payload_end = payload_start + self.payload.len();
323            buf[payload_start..payload_end].copy_from_slice(self.payload);
324
325            // Append CRC — use the declared value to preserve round-trip identity.
326            let crc = self
327                .crc32
328                .unwrap_or_else(|| crc::compute(&buf[..payload_end]));
329            let crc_start = payload_end;
330            buf[crc_start..crc_start + CRC_LEN].copy_from_slice(&crc.to_be_bytes());
331        } else {
332            // Short form: no extension header, no CRC.
333            let payload_end = MIN_HEADER_LEN + self.payload.len();
334            buf[MIN_HEADER_LEN..payload_end].copy_from_slice(self.payload);
335        }
336
337        Ok(need)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use alloc::vec;
345    use alloc::vec::Vec;
346    use broadcast_common::crc32_mpeg2::compute as crc32;
347
348    // ── Helper: build a minimal long-form section with correct CRC ───────────
349
350    /// Build a syntactically valid long-form section byte vector.
351    ///
352    /// Layout: [table_id, flags+len_hi, len_lo, ext_hi, ext_lo,
353    ///          ver_cni, sec_num, last_sec_num, ...payload..., crc(4)]
354    fn make_long_section(
355        table_id: u8,
356        extension_id: u16,
357        version: u8,
358        current_next: bool,
359        section_number: u8,
360        last_section_number: u8,
361        payload: &[u8],
362    ) -> Vec<u8> {
363        // section_length = 5 (extension header) + payload.len() + 4 (CRC)
364        let section_length: u16 = (5 + payload.len() + 4) as u16;
365
366        // reserved(2) | version(5) | current_next(1)
367        let ver_cni = 0xC0u8 | ((version & 0x1F) << 1) | (current_next as u8);
368        let mut buf: Vec<u8> = vec![
369            table_id,
370            // section_syntax_indicator=1, private_indicator=0, reserved=0b11, upper 4 bits of section_length
371            0x80 | 0x30 | ((section_length >> 8) as u8 & 0x0F),
372            (section_length & 0xFF) as u8,
373            (extension_id >> 8) as u8,
374            (extension_id & 0xFF) as u8,
375            ver_cni,
376            section_number,
377            last_section_number,
378        ];
379        buf.extend_from_slice(payload);
380
381        // Compute CRC over bytes so far, append as big-endian u32.
382        let crc = crc32(&buf);
383        buf.push((crc >> 24) as u8);
384        buf.push((crc >> 16) as u8);
385        buf.push((crc >> 8) as u8);
386        buf.push(crc as u8);
387
388        buf
389    }
390
391    // ── Test 1 ───────────────────────────────────────────────────────────────
392
393    #[test]
394    fn parse_rejects_buffer_shorter_than_3_bytes() {
395        for bad_len in [0usize, 1, 2] {
396            let buf = vec![0x00u8; bad_len];
397            let err = Section::parse(&buf).unwrap_err();
398            assert!(
399                matches!(err, Error::BufferTooShort { need: 3, have, .. } if have == bad_len),
400                "expected BufferTooShort for len={bad_len}, got {err:?}"
401            );
402        }
403    }
404
405    // ── Test 2 ───────────────────────────────────────────────────────────────
406
407    #[test]
408    fn parse_reads_table_id_syntax_indicator_and_length() {
409        // Construct a minimal 13-byte long-form section with no payload.
410        // section_length = 5 (extension header) + 0 (payload) + 4 (CRC) = 9
411        // BUT we need section_length >= min for a valid section: 9 → total = 12, which is 12 bytes, not 13.
412        // Let's use 1 byte of payload so section_length = 10, total = 13.
413        let raw = make_long_section(0x42, 0x1234, 3, true, 0, 0, &[0xAB]);
414        assert_eq!(raw.len(), 13);
415
416        let section = Section::parse(&raw).unwrap();
417        assert_eq!(section.table_id, 0x42);
418        assert!(section.section_syntax_indicator);
419        // section_length = 5 + 1 + 4 = 10
420        assert_eq!(section.section_length, 10);
421    }
422
423    // ── Test 3 ───────────────────────────────────────────────────────────────
424
425    #[test]
426    fn parse_rejects_when_section_length_exceeds_buffer() {
427        // Build a 3-byte header that claims section_length = 100 bytes of data.
428        // Buffer is only 3 bytes (just the header), so total = 103 > 3.
429        let buf = [
430            0x00u8, // table_id
431            0x80u8, // section_syntax_indicator=1, section_length upper nibble = 0
432            100u8,  // section_length lower byte = 100 → total = 103
433        ];
434        let err = Section::parse(&buf).unwrap_err();
435        assert!(
436            matches!(
437                err,
438                Error::SectionLengthOverflow {
439                    declared: 103,
440                    available: 3
441                }
442            ),
443            "expected SectionLengthOverflow, got {err:?}"
444        );
445    }
446
447    // ── Test 4 ───────────────────────────────────────────────────────────────
448
449    #[test]
450    fn parse_reads_extension_id_version_current_next_section_numbers() {
451        let raw = make_long_section(
452            0x02,          // PMT table_id
453            0xBEEF,        // extension_id / program_number
454            7,             // version_number
455            true,          // current_next
456            2,             // section_number
457            5,             // last_section_number
458            &[0x00, 0x00], // dummy payload
459        );
460
461        let section = Section::parse(&raw).unwrap();
462        assert_eq!(section.extension_id, 0xBEEF);
463        assert_eq!(section.version_number, 7);
464        assert!(section.current_next_indicator);
465        assert_eq!(section.section_number, 2);
466        assert_eq!(section.last_section_number, 5);
467    }
468
469    #[test]
470    fn parse_reads_current_next_indicator_false() {
471        // Same as test 4 but with current_next = false (bit 0 of byte 5 cleared).
472        let raw = make_long_section(
473            0x02,   // PMT table_id
474            0xBEEF, // extension_id
475            7,      // version_number
476            false,  // current_next — the field under test
477            2,      // section_number
478            5,      // last_section_number
479            &[0x00, 0x00],
480        );
481
482        let section = Section::parse(&raw).unwrap();
483        assert!(!section.current_next_indicator);
484        // Confirm the other fields are unaffected by flipping bit 0.
485        assert_eq!(section.extension_id, 0xBEEF);
486        assert_eq!(section.version_number, 7);
487        assert_eq!(section.section_number, 2);
488        assert_eq!(section.last_section_number, 5);
489    }
490
491    // ── Test 5 ───────────────────────────────────────────────────────────────
492
493    #[test]
494    fn payload_slice_excludes_header_and_crc() {
495        let inner_payload = &[0x01u8, 0x02, 0x03, 0x04, 0x05];
496        let raw = make_long_section(0x42, 0x0001, 0, true, 0, 0, inner_payload);
497
498        let section = Section::parse(&raw).unwrap();
499        assert_eq!(section.payload(), inner_payload);
500    }
501
502    // ── Test 6 ───────────────────────────────────────────────────────────────
503
504    #[test]
505    fn validate_crc_accepts_matching_crc32() {
506        let raw = make_long_section(0x00, 0x0001, 1, true, 0, 0, &[0xDE, 0xAD, 0xBE, 0xEF]);
507        let section = Section::parse(&raw).unwrap();
508        section.validate_crc(&raw).expect("CRC should match");
509    }
510
511    // ── Test 7 ───────────────────────────────────────────────────────────────
512
513    #[test]
514    fn validate_crc_rejects_flipped_bit() {
515        let mut raw = make_long_section(0x00, 0x0001, 1, true, 0, 0, &[0xDE, 0xAD, 0xBE, 0xEF]);
516        // Flip a bit inside the payload (byte 8 is the first payload byte) BEFORE
517        // parsing.  The 4-byte CRC at the tail of `raw` was computed over the
518        // original (un-flipped) bytes and is NOT updated here, so after the flip:
519        //   • `raw[..raw.len()-4]` contains corrupted data, and
520        //   • `raw[raw.len()-4..]` still holds the CRC of the original data.
521        // Parsing after the flip captures that old CRC into `section.crc32`.
522        // `validate_crc` then recomputes over the corrupted bytes and detects the
523        // mismatch — which is exactly the invariant we are testing.
524        // (Parsing before the flip would immutably borrow `raw` for the lifetime
525        // of `section` — the compiler would then reject `raw[8] ^= 0x01` because
526        // a mutable borrow conflicts with any live shared borrow.)
527        raw[8] ^= 0x01;
528
529        let section = Section::parse(&raw).unwrap();
530        let err = section.validate_crc(&raw).unwrap_err();
531        assert!(
532            matches!(err, Error::CrcMismatch { .. }),
533            "expected CrcMismatch, got {err:?}"
534        );
535    }
536
537    // ── Test (Fix 1 TDD) ─────────────────────────────────────────────────────
538
539    #[test]
540    fn validate_crc_rejects_raw_slice_shorter_than_crc_len() {
541        let raw = make_long_section(0x42, 0x0001, 0, true, 0, 0, &[0xDE, 0xAD]);
542        let section = Section::parse(&raw).unwrap();
543        // Pass an empty slice — shorter than CRC_LEN bytes.
544        let err = section.validate_crc(&[]).unwrap_err();
545        assert!(
546            matches!(err, Error::BufferTooShort { need: CRC_LEN, .. }),
547            "expected BufferTooShort(need=CRC_LEN), got {err:?}"
548        );
549    }
550
551    // ── Test 8 ───────────────────────────────────────────────────────────────
552
553    #[test]
554    fn short_form_section_has_no_crc() {
555        // TDT-style short-form section: section_syntax_indicator = 0.
556        // section_length = 5 (5 bytes of payload), total = 8 bytes.
557        let buf = [
558            0x70u8, // table_id (TDT)
559            0x70u8, // SSI=0, private=1, reserved=0b11, upper nibble of section_length=0
560            0x05u8, // section_length = 5
561            // 5 bytes of "UTC time" payload
562            0xE0, 0x00, 0x00, 0x00, 0x00,
563        ];
564
565        let section = Section::parse(&buf).unwrap();
566        assert!(!section.section_syntax_indicator);
567        assert!(section.crc32.is_none());
568        // validate_crc on short-form should return Ok(()) vacuously.
569        section
570            .validate_crc(&buf)
571            .expect("short-form: no CRC to validate");
572        // Payload is the 5 bytes after the 3-byte header.
573        assert_eq!(section.payload(), &buf[3..]);
574    }
575
576    // ── W5 (#1074): serialize rejects a hand-edited length/payload mismatch ──
577
578    #[test]
579    fn serialize_rejects_oversized_payload_for_declared_section_length() {
580        // A caller edited `payload` to be longer than `section_length`
581        // implies (all fields are `pub`). Pre-fix, `serialized_len()` sized
582        // `buf` from `section_length` while the copy indexed by
583        // `payload.len()`, so this panicked with an out-of-bounds slice
584        // index instead of erroring.
585        let raw = make_long_section(0x42, 0x0001, 0, true, 0, 0, &[0xAA, 0xBB]);
586        let mut section = Section::parse(&raw).unwrap();
587        let oversized = vec![0u8; section.payload.len() + 1];
588        section.payload = &oversized;
589
590        let mut buf = vec![0u8; section.serialized_len()];
591        let err = section.serialize_into(&mut buf).unwrap_err();
592        assert!(
593            matches!(err, Error::SectionPayloadLengthMismatch { actual, .. } if actual == oversized.len()),
594            "expected SectionPayloadLengthMismatch, got {err:?}"
595        );
596    }
597
598    #[test]
599    fn serialize_rejects_undersized_payload_for_declared_section_length() {
600        let raw = make_long_section(0x42, 0x0001, 0, true, 0, 0, &[0xAA, 0xBB, 0xCC]);
601        let mut section = Section::parse(&raw).unwrap();
602        let undersized = &[0xAAu8][..];
603        section.payload = undersized;
604
605        // Buffer sized generously so an undersized payload can't be caught by
606        // `OutputBufferTooSmall` first — it must be caught by the length
607        // consistency check.
608        let mut buf = vec![0u8; section.serialized_len() + 16];
609        let err = section.serialize_into(&mut buf).unwrap_err();
610        assert!(
611            matches!(err, Error::SectionPayloadLengthMismatch { actual, .. } if actual == undersized.len()),
612            "expected SectionPayloadLengthMismatch, got {err:?}"
613        );
614    }
615
616    #[test]
617    fn serialize_accepts_consistent_section_round_trip() {
618        // Sanity: a self-consistent Section (as produced by `parse`) still
619        // serializes byte-identically after the new validation.
620        let raw = make_long_section(0x42, 0x0001, 3, true, 1, 2, &[0x01, 0x02, 0x03]);
621        let section = Section::parse(&raw).unwrap();
622        let mut buf = vec![0u8; section.serialized_len()];
623        let n = section.serialize_into(&mut buf).unwrap();
624        assert_eq!(&buf[..n], raw.as_slice());
625    }
626}