Skip to main content

lvqr_codec/
scte35.rs

1//! SCTE-35 splice_info_section parser for ad-marker passthrough.
2//!
3//! Parses the binary section format from ANSI/SCTE 35-2024 section 8.1.
4//! The parser is intentionally minimum-viable: it extracts the timing
5//! and command-type fields LVQR's HLS / DASH egress renderers need
6//! (event_id, splice_time PTS, break_duration, command_type) and
7//! preserves the entire section verbatim for re-emission downstream.
8//! No semantic interpretation, no descriptor decoding beyond what the
9//! egress wire shapes require.
10//!
11//! ## Wire shape
12//!
13//! The splice_info_section is an MPEG-2 private section with table_id
14//! 0xFC. Its layout per SCTE 35-2024 section 8.1:
15//!
16//! ```text
17//! table_id                        8 bits   (0xFC)
18//! section_syntax_indicator        1 bit
19//! private_indicator               1 bit
20//! sap_type                        2 bits
21//! section_length                  12 bits
22//! protocol_version                8 bits
23//! encrypted_packet                1 bit
24//! encryption_algorithm            6 bits
25//! pts_adjustment                  33 bits
26//! cw_index                        8 bits
27//! tier                            12 bits
28//! splice_command_length           12 bits
29//! splice_command_type             8 bits
30//! splice_command()                variable
31//! descriptor_loop_length          16 bits
32//! splice_descriptor()*            variable
33//! [if encrypted: alignment + E_CRC_32]
34//! CRC_32                          32 bits
35//! ```
36//!
37//! ## CRC verification
38//!
39//! Per spec the trailing 32-bit CRC is the MPEG-2 polynomial
40//! (0x04C11DB7) with initial 0xFFFFFFFF, no input/output reflection,
41//! no final XOR. The parser computes the CRC over every byte from
42//! table_id through the byte before the trailing CRC and rejects
43//! sections whose computed CRC does not match the wire value. Buggy
44//! publishers that emit malformed sections are dropped at the parser
45//! boundary; the integration layer counts the drops via
46//! `lvqr_scte35_drops_total{reason="crc"}`.
47//!
48//! ## Out of scope (passthrough only)
49//!
50//! * No descriptor decoding (segmentation_descriptor, etc.). The raw
51//!   descriptor bytes ride along inside the preserved section blob.
52//! * No semantic interpretation of splice_insert / time_signal beyond
53//!   surfacing the splice_time PTS that egress renderers need.
54//! * No encryption support (encrypted_packet sections are accepted
55//!   for passthrough but the encrypted payload is not decoded).
56//! * No SCTE-104 (a different studio-side wire format).
57
58use crate::error::CodecError;
59use bytes::Bytes;
60
61/// SCTE-35 splice_info_section table_id per spec.
62pub const TABLE_ID: u8 = 0xFC;
63
64/// splice_command_type values per SCTE 35-2024 table 7.
65pub const CMD_SPLICE_NULL: u8 = 0x00;
66pub const CMD_SPLICE_SCHEDULE: u8 = 0x04;
67pub const CMD_SPLICE_INSERT: u8 = 0x05;
68pub const CMD_TIME_SIGNAL: u8 = 0x06;
69pub const CMD_BANDWIDTH_RESERVATION: u8 = 0x07;
70pub const CMD_PRIVATE_COMMAND: u8 = 0xFF;
71
72/// Parsed view of a splice_info_section, preserving the raw bytes for
73/// downstream passthrough alongside the timing fields HLS / DASH
74/// renderers need.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SpliceInfo {
77    /// `splice_command_type` field (one of `CMD_*`).
78    pub command_type: u8,
79    /// `pts_adjustment` field. Added to every splice_time PTS by the
80    /// receiving decoder. Surfaced for completeness; the egress
81    /// renderers usually want the absolute PTS via [`SpliceInfo::pts`].
82    pub pts_adjustment: u64,
83    /// 33-bit splice_time PTS extracted from the splice_command, when
84    /// present and time_specified. None when:
85    /// * the command type has no splice_time (splice_null, splice_schedule,
86    ///   bandwidth_reservation, private_command),
87    /// * the splice_insert is splice_immediate (no pre-roll PTS),
88    /// * splice_insert.cancel_indicator is set,
89    /// * splice_insert is per-component (no program splice_time),
90    /// * splice_time.time_specified_flag is 0 ("immediate").
91    pub pts: Option<u64>,
92    /// `break_duration` from splice_insert when the duration_flag is
93    /// set. None for time_signal and for splice_insert without duration.
94    pub duration: Option<u64>,
95    /// `splice_event_id` from splice_insert. None for command types
96    /// other than splice_insert.
97    pub event_id: Option<u32>,
98    /// True when `splice_event_cancel_indicator` is set on a
99    /// splice_insert. Egress renderers may emit a cancellation
100    /// SCTE35-CMD entry.
101    pub cancel: bool,
102    /// True when `out_of_network_indicator` is set on a splice_insert
103    /// (signals an ad break out -- "going to ad"). Drives the choice
104    /// of HLS SCTE35-OUT vs SCTE35-IN attribute.
105    pub out_of_network: bool,
106    /// Raw splice_info_section bytes from table_id through CRC_32,
107    /// preserved for passthrough into HLS DATERANGE SCTE35-* hex
108    /// attributes and DASH EventStream / Event base64 bodies.
109    pub raw: Bytes,
110}
111
112impl SpliceInfo {
113    /// Absolute PTS of the splice = `pts_adjustment + splice_time.pts`,
114    /// when both are available. Wraps modulo 2^33 per SCTE 35.
115    pub fn absolute_pts(&self) -> Option<u64> {
116        self.pts.map(|p| (p + self.pts_adjustment) & ((1u64 << 33) - 1))
117    }
118}
119
120/// Parse a SCTE-35 splice_info_section from raw bytes.
121///
122/// Performs CRC_32 verification per SCTE 35-2024 section 11.1; sections
123/// with a wrong trailing CRC return [`CodecError::Scte35BadCrc`].
124/// Truncated or under-length sections return
125/// [`CodecError::EndOfStream`].
126pub fn parse_splice_info_section(bytes: &[u8]) -> Result<SpliceInfo, CodecError> {
127    if bytes.len() < 17 {
128        return Err(CodecError::EndOfStream {
129            needed: 17,
130            remaining: bytes.len(),
131        });
132    }
133    if bytes[0] != TABLE_ID {
134        return Err(CodecError::Scte35Malformed("table_id != 0xFC"));
135    }
136
137    // section_length is the number of bytes following the section_length
138    // field (i.e. starting at bytes[3]) through the trailing CRC.
139    let section_length = (((bytes[1] & 0x0F) as usize) << 8) | bytes[2] as usize;
140    let total_len = 3 + section_length;
141    if total_len > bytes.len() {
142        return Err(CodecError::EndOfStream {
143            needed: total_len,
144            remaining: bytes.len(),
145        });
146    }
147    if section_length < 15 {
148        // Bare minimum: protocol_version(1) + flags+pts_adj(5) + cw_index(1)
149        // + tier+splice_command_length(3) + splice_command_type(1) +
150        // descriptor_loop_length(2) + CRC_32(4) -- before any command body.
151        return Err(CodecError::Scte35Malformed("section_length too short"));
152    }
153
154    // Verify CRC_32 over [0..total_len-4].
155    let crc_offset = total_len - 4;
156    let computed = crc32_mpeg2(&bytes[..crc_offset]);
157    let wire = ((bytes[crc_offset] as u32) << 24)
158        | ((bytes[crc_offset + 1] as u32) << 16)
159        | ((bytes[crc_offset + 2] as u32) << 8)
160        | (bytes[crc_offset + 3] as u32);
161    if computed != wire {
162        return Err(CodecError::Scte35BadCrc { computed, wire });
163    }
164
165    let encrypted = bytes[4] & 0x80 != 0;
166    // pts_adjustment: 1 bit at bytes[4] LSB then bytes[5..=8].
167    let pts_adjustment = (((bytes[4] & 0x01) as u64) << 32)
168        | ((bytes[5] as u64) << 24)
169        | ((bytes[6] as u64) << 16)
170        | ((bytes[7] as u64) << 8)
171        | (bytes[8] as u64);
172
173    // Layout from byte 10: tier(12) | splice_command_length(12) | splice_command_type(8).
174    // Byte 10 = tier[11..4], byte 11 = tier[3..0] | scl[11..8],
175    // byte 12 = scl[7..0], byte 13 = splice_command_type.
176    let splice_command_length = (((bytes[11] & 0x0F) as usize) << 8) | bytes[12] as usize;
177    let splice_command_type = bytes[13];
178
179    let cmd_start = 14;
180    // The 12-bit splice_command_length value 0xFFF means "the splice
181    // command extends to the end of the section minus descriptor_loop"
182    // (per SCTE 35 spec note); but for passthrough we never re-walk the
183    // command, so we only use it as a sanity bound.
184    let cmd_end = if splice_command_length == 0xFFF {
185        // Heuristic: scan forward to find descriptor_loop_length such
186        // that everything fits. For simplicity we delegate to the
187        // command-specific parser to know its own length.
188        cmd_start
189    } else {
190        cmd_start + splice_command_length
191    };
192    if cmd_end > crc_offset {
193        return Err(CodecError::Scte35Malformed("splice_command extends past section"));
194    }
195
196    let mut event_id = None;
197    let mut cancel = false;
198    let mut out_of_network = false;
199    let mut pts = None;
200    let mut duration = None;
201
202    if !encrypted {
203        match splice_command_type {
204            CMD_SPLICE_INSERT => {
205                let parsed = parse_splice_insert(&bytes[cmd_start..crc_offset])?;
206                event_id = Some(parsed.event_id);
207                cancel = parsed.cancel;
208                out_of_network = parsed.out_of_network;
209                pts = parsed.pts;
210                duration = parsed.duration;
211            }
212            CMD_TIME_SIGNAL => {
213                pts = parse_splice_time(&bytes[cmd_start..crc_offset])?;
214            }
215            // splice_null, splice_schedule, bandwidth_reservation,
216            // private_command: no per-event timing surfaced for v1
217            // passthrough; the raw section carries everything.
218            _ => {}
219        }
220    }
221
222    let raw = Bytes::copy_from_slice(&bytes[..total_len]);
223
224    Ok(SpliceInfo {
225        command_type: splice_command_type,
226        pts_adjustment,
227        pts,
228        duration,
229        event_id,
230        cancel,
231        out_of_network,
232        raw,
233    })
234}
235
236/// Helper struct: parsed splice_insert command body fields.
237struct ParsedSpliceInsert {
238    event_id: u32,
239    cancel: bool,
240    out_of_network: bool,
241    pts: Option<u64>,
242    duration: Option<u64>,
243}
244
245/// Parse a splice_insert() command body per SCTE 35-2024 section 9.7.3.
246///
247/// Returns the event_id and the timing/flag fields LVQR egress needs.
248/// Returns [`CodecError::EndOfStream`] when the command body is short
249/// of the bytes the field layout requires.
250fn parse_splice_insert(body: &[u8]) -> Result<ParsedSpliceInsert, CodecError> {
251    if body.len() < 5 {
252        return Err(CodecError::EndOfStream {
253            needed: 5,
254            remaining: body.len(),
255        });
256    }
257    let event_id = ((body[0] as u32) << 24) | ((body[1] as u32) << 16) | ((body[2] as u32) << 8) | (body[3] as u32);
258    let cancel = body[4] & 0x80 != 0;
259
260    let mut pts = None;
261    let mut duration = None;
262    let mut out_of_network = false;
263
264    if !cancel {
265        if body.len() < 6 {
266            return Err(CodecError::EndOfStream {
267                needed: 6,
268                remaining: body.len(),
269            });
270        }
271        let flags = body[5];
272        out_of_network = flags & 0x80 != 0;
273        let program_splice = flags & 0x40 != 0;
274        let duration_flag = flags & 0x20 != 0;
275        let splice_immediate = flags & 0x10 != 0;
276
277        let mut cursor = 6;
278        if program_splice && !splice_immediate {
279            let (parsed_pts, consumed) = parse_splice_time_inline(&body[cursor..])?;
280            pts = parsed_pts;
281            cursor += consumed;
282        }
283        if !program_splice {
284            // Per-component splice. Skip the component loop; we do not
285            // surface per-component PTS for v1 passthrough.
286            if cursor >= body.len() {
287                return Err(CodecError::EndOfStream {
288                    needed: cursor + 1,
289                    remaining: body.len(),
290                });
291            }
292            let component_count = body[cursor] as usize;
293            cursor += 1;
294            for _ in 0..component_count {
295                if cursor >= body.len() {
296                    return Err(CodecError::EndOfStream {
297                        needed: cursor + 1,
298                        remaining: body.len(),
299                    });
300                }
301                cursor += 1; // component_tag
302                if !splice_immediate {
303                    let (_pts, consumed) = parse_splice_time_inline(&body[cursor..])?;
304                    cursor += consumed;
305                }
306            }
307        }
308        if duration_flag {
309            if body.len() < cursor + 5 {
310                return Err(CodecError::EndOfStream {
311                    needed: cursor + 5,
312                    remaining: body.len(),
313                });
314            }
315            // break_duration: 1 bit auto_return, 6 bits reserved, 33 bits duration.
316            let dur = (((body[cursor] & 0x01) as u64) << 32)
317                | ((body[cursor + 1] as u64) << 24)
318                | ((body[cursor + 2] as u64) << 16)
319                | ((body[cursor + 3] as u64) << 8)
320                | (body[cursor + 4] as u64);
321            duration = Some(dur);
322        }
323    }
324
325    Ok(ParsedSpliceInsert {
326        event_id,
327        cancel,
328        out_of_network,
329        pts,
330        duration,
331    })
332}
333
334/// Parse a splice_time() field per SCTE 35-2024 section 9.4.1.
335///
336/// Returns the absolute splice_time PTS when time_specified_flag is 1,
337/// or None when 0 ("immediate"). Used for time_signal commands where
338/// the entire body is one splice_time.
339fn parse_splice_time(body: &[u8]) -> Result<Option<u64>, CodecError> {
340    Ok(parse_splice_time_inline(body)?.0)
341}
342
343/// Inline version of [`parse_splice_time`] that returns both the value
344/// and the byte count consumed (1 byte for time_specified_flag=0,
345/// 5 bytes for time_specified_flag=1).
346fn parse_splice_time_inline(body: &[u8]) -> Result<(Option<u64>, usize), CodecError> {
347    if body.is_empty() {
348        return Err(CodecError::EndOfStream {
349            needed: 1,
350            remaining: 0,
351        });
352    }
353    let time_specified = body[0] & 0x80 != 0;
354    if !time_specified {
355        return Ok((None, 1));
356    }
357    if body.len() < 5 {
358        return Err(CodecError::EndOfStream {
359            needed: 5,
360            remaining: body.len(),
361        });
362    }
363    let pts = (((body[0] & 0x01) as u64) << 32)
364        | ((body[1] as u64) << 24)
365        | ((body[2] as u64) << 16)
366        | ((body[3] as u64) << 8)
367        | (body[4] as u64);
368    Ok((Some(pts), 5))
369}
370
371/// CRC-32/MPEG-2: polynomial 0x04C11DB7, initial 0xFFFFFFFF, no input
372/// or output reflection, no final XOR. Used by SCTE-35 sections and by
373/// ISO/IEC 13818-1 PSI tables.
374fn crc32_mpeg2(data: &[u8]) -> u32 {
375    let mut crc: u32 = 0xFFFF_FFFF;
376    for &byte in data {
377        crc ^= (byte as u32) << 24;
378        for _ in 0..8 {
379            crc = if crc & 0x8000_0000 != 0 {
380                (crc << 1) ^ 0x04C1_1DB7
381            } else {
382                crc << 1
383            };
384        }
385    }
386    crc
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    /// Build a splice_info_section programmatically and append a valid
394    /// CRC_32 trailer. Used by the per-command-type test cases below.
395    fn build_section(prefix: &[u8], command_body: &[u8], descriptors: &[u8]) -> Vec<u8> {
396        // Total length excluding CRC = prefix(13) + command + desc_loop_len(2) + descs.
397        let total_minus_crc = prefix.len() + command_body.len() + 2 + descriptors.len();
398        let total = total_minus_crc + 4;
399        let section_length = total - 3;
400        let mut out = Vec::with_capacity(total);
401        out.push(TABLE_ID);
402        // section_syntax=0, private=0, sap_type=11, section_length high 4.
403        out.push(0x30 | ((section_length >> 8) as u8 & 0x0F));
404        out.push(section_length as u8);
405        out.extend_from_slice(&prefix[3..]);
406        // After the prefix slice we have the splice_command_type at the
407        // last byte of the 13-byte prefix; we need to update
408        // splice_command_length in [10..12] to match command_body.len().
409        let cmd_len = command_body.len();
410        out[11] = (out[11] & 0xF0) | ((cmd_len >> 8) as u8 & 0x0F);
411        out[12] = cmd_len as u8;
412        out.extend_from_slice(command_body);
413        out.push((descriptors.len() >> 8) as u8);
414        out.push(descriptors.len() as u8);
415        out.extend_from_slice(descriptors);
416        let crc = crc32_mpeg2(&out);
417        out.push((crc >> 24) as u8);
418        out.push((crc >> 16) as u8);
419        out.push((crc >> 8) as u8);
420        out.push(crc as u8);
421        out
422    }
423
424    /// Default 13-byte prefix: protocol_version=0, no encryption,
425    /// pts_adjustment=0, cw_index=0, tier=0xFFF, splice_command_length=0
426    /// (overridden by build_section), splice_command_type set by caller.
427    fn default_prefix(command_type: u8) -> Vec<u8> {
428        vec![
429            TABLE_ID,
430            0x00, // section_length high (placeholder)
431            0x00, // section_length low (placeholder)
432            0x00, // protocol_version
433            0x00, // encrypted=0, encryption_alg=0, pts_adj high bit
434            0x00,
435            0x00,
436            0x00,
437            0x00, // pts_adjustment lower 32 bits
438            0x00, // cw_index
439            0xFF, // tier high 8 bits
440            0xF0, // tier low 4 bits | splice_command_length high 4 (placeholder)
441            0x00, // splice_command_length low 8 (placeholder)
442            command_type,
443        ]
444    }
445
446    #[test]
447    fn parses_splice_null() {
448        let prefix = default_prefix(CMD_SPLICE_NULL);
449        let bytes = build_section(&prefix, &[], &[]);
450        let info = parse_splice_info_section(&bytes).expect("splice_null parses");
451        assert_eq!(info.command_type, CMD_SPLICE_NULL);
452        assert!(info.pts.is_none());
453        assert!(info.duration.is_none());
454        assert!(info.event_id.is_none());
455        assert!(!info.cancel);
456        assert_eq!(&info.raw[..], &bytes[..]);
457    }
458
459    #[test]
460    fn parses_time_signal_with_pts() {
461        let prefix = default_prefix(CMD_TIME_SIGNAL);
462        // splice_time: time_specified_flag=1, reserved=63, pts_time=0x12345678.
463        let pts: u64 = 0x1_2345_6789;
464        // splice_time(): time_specified=1 | reserved | pts high bit, then
465        // 32 lower PTS bits.
466        let command_body = vec![
467            0xFE | ((pts >> 32) as u8 & 0x01),
468            (pts >> 24) as u8,
469            (pts >> 16) as u8,
470            (pts >> 8) as u8,
471            pts as u8,
472        ];
473        let bytes = build_section(&prefix, &command_body, &[]);
474        let info = parse_splice_info_section(&bytes).expect("time_signal parses");
475        assert_eq!(info.command_type, CMD_TIME_SIGNAL);
476        assert_eq!(info.pts, Some(pts));
477    }
478
479    #[test]
480    fn parses_time_signal_immediate() {
481        let prefix = default_prefix(CMD_TIME_SIGNAL);
482        // splice_time: time_specified_flag=0 -> single byte with reserved bits.
483        let command_body = vec![0x7F];
484        let bytes = build_section(&prefix, &command_body, &[]);
485        let info = parse_splice_info_section(&bytes).expect("time_signal immediate parses");
486        assert!(info.pts.is_none());
487    }
488
489    #[test]
490    fn parses_splice_insert_with_duration_and_pts() {
491        let prefix = default_prefix(CMD_SPLICE_INSERT);
492        let event_id: u32 = 0xDEAD_BEEF;
493        let pts: u64 = 0x0_FFFF_FFFF;
494        let duration: u64 = 0x1_0000_0000;
495        // splice_insert body fields per SCTE 35-2024 section 9.7.3:
496        // event_id(4) + flags(1: cancel=0, reserved=7) + flags(1: out=1,
497        // program=1, duration=1, immediate=0, reserved=4) +
498        // splice_time(5) + break_duration(5) + unique_program_id(2) +
499        // avail_num(1) + avails_expected(1).
500        let command_body = vec![
501            (event_id >> 24) as u8,
502            (event_id >> 16) as u8,
503            (event_id >> 8) as u8,
504            event_id as u8,
505            0x7F, // cancel=0, reserved=0x7F (all reserved bits set per spec)
506            0xEF, // out=1, program=1, duration=1, immediate=0, reserved=1111
507            0xFE | ((pts >> 32) as u8 & 0x01),
508            (pts >> 24) as u8,
509            (pts >> 16) as u8,
510            (pts >> 8) as u8,
511            pts as u8,
512            0xFE | ((duration >> 32) as u8 & 0x01),
513            (duration >> 24) as u8,
514            (duration >> 16) as u8,
515            (duration >> 8) as u8,
516            duration as u8,
517            0x00,
518            0x01, // unique_program_id
519            0x00, // avail_num
520            0x00, // avails_expected
521        ];
522        let bytes = build_section(&prefix, &command_body, &[]);
523        let info = parse_splice_info_section(&bytes).expect("splice_insert parses");
524        assert_eq!(info.command_type, CMD_SPLICE_INSERT);
525        assert_eq!(info.event_id, Some(event_id));
526        assert!(!info.cancel);
527        assert!(info.out_of_network);
528        assert_eq!(info.pts, Some(pts));
529        assert_eq!(info.duration, Some(duration));
530    }
531
532    #[test]
533    fn parses_splice_insert_cancel_no_body() {
534        let prefix = default_prefix(CMD_SPLICE_INSERT);
535        let event_id: u32 = 0x1234_5678;
536        // splice_insert body with cancel_indicator=1 (no further fields).
537        let command_body = vec![
538            (event_id >> 24) as u8,
539            (event_id >> 16) as u8,
540            (event_id >> 8) as u8,
541            event_id as u8,
542            0xFF, // cancel=1 | reserved=0x7F
543        ];
544        let bytes = build_section(&prefix, &command_body, &[]);
545        let info = parse_splice_info_section(&bytes).expect("splice_insert cancel parses");
546        assert_eq!(info.event_id, Some(event_id));
547        assert!(info.cancel);
548        assert!(info.pts.is_none());
549        assert!(info.duration.is_none());
550    }
551
552    #[test]
553    fn rejects_bad_crc() {
554        let prefix = default_prefix(CMD_SPLICE_NULL);
555        let mut bytes = build_section(&prefix, &[], &[]);
556        // Flip a bit in the section body so the CRC stops matching.
557        bytes[3] ^= 0x01;
558        let err = parse_splice_info_section(&bytes).expect_err("bad CRC must reject");
559        assert!(matches!(err, CodecError::Scte35BadCrc { .. }), "{err:?}");
560    }
561
562    #[test]
563    fn rejects_truncated() {
564        let prefix = default_prefix(CMD_SPLICE_NULL);
565        let bytes = build_section(&prefix, &[], &[]);
566        let err = parse_splice_info_section(&bytes[..10]).expect_err("truncated must reject");
567        assert!(matches!(err, CodecError::EndOfStream { .. }), "{err:?}");
568    }
569
570    #[test]
571    fn rejects_wrong_table_id() {
572        let prefix = default_prefix(CMD_SPLICE_NULL);
573        let mut bytes = build_section(&prefix, &[], &[]);
574        bytes[0] = 0x00;
575        let err = parse_splice_info_section(&bytes).expect_err("wrong table_id");
576        assert!(matches!(err, CodecError::Scte35Malformed(_)), "{err:?}");
577    }
578
579    #[test]
580    fn pts_adjustment_round_trips() {
581        // Construct a splice_null with a known pts_adjustment, then
582        // verify the parsed value matches.
583        let mut prefix = default_prefix(CMD_SPLICE_NULL);
584        let pts_adj: u64 = 0x1_FFFF_FFFE;
585        prefix[4] = (prefix[4] & 0xFE) | ((pts_adj >> 32) as u8 & 0x01);
586        prefix[5] = (pts_adj >> 24) as u8;
587        prefix[6] = (pts_adj >> 16) as u8;
588        prefix[7] = (pts_adj >> 8) as u8;
589        prefix[8] = pts_adj as u8;
590        let bytes = build_section(&prefix, &[], &[]);
591        let info = parse_splice_info_section(&bytes).expect("parses");
592        assert_eq!(info.pts_adjustment, pts_adj);
593    }
594
595    #[test]
596    fn absolute_pts_wraps_at_33_bits() {
597        let info = SpliceInfo {
598            command_type: CMD_TIME_SIGNAL,
599            pts_adjustment: 1,
600            pts: Some((1u64 << 33) - 1),
601            duration: None,
602            event_id: None,
603            cancel: false,
604            out_of_network: false,
605            raw: Bytes::new(),
606        };
607        assert_eq!(info.absolute_pts(), Some(0));
608    }
609
610    #[test]
611    fn crc32_mpeg2_known_vector() {
612        // Standard test vector for MPEG-2 CRC: input "123456789" yields
613        // 0x0376E6E7.
614        assert_eq!(crc32_mpeg2(b"123456789"), 0x0376E6E7);
615    }
616
617    /// Adversarial proptest: drive the parser with arbitrary single-
618    /// byte mutations on a valid splice_info_section and assert the
619    /// outcome is one of (a) accepts the mutation if the mutated byte
620    /// happened to land somewhere CRC-recoverable AND the mutation
621    /// happened to keep the section's structural fields valid (rare),
622    /// (b) rejects with `Scte35BadCrc` (most common -- the CRC stops
623    /// matching), (c) rejects with another structural error
624    /// (`Scte35Malformed`, `EndOfStream`, etc.). The contract under
625    /// test is that the parser NEVER panics on adversarial input and
626    /// NEVER silently accepts a mutated section without re-deriving
627    /// the CRC from the mutated bytes.
628    ///
629    /// Closes the audit gap that the existing `rejects_bad_crc` test
630    /// only ever flips one specific bit in a single section shape.
631    use proptest::prelude::*;
632
633    proptest! {
634        #![proptest_config(ProptestConfig {
635            cases: 256,
636            ..ProptestConfig::default()
637        })]
638        #[test]
639        fn parse_handles_arbitrary_byte_mutations_without_panic(
640            byte_index in 0usize..64,
641            xor_mask in 1u8..=0xFFu8,
642        ) {
643            // Build a valid splice_null section (the smallest variant
644            // we have). 17 bytes total: 13 prefix + 0 command body +
645            // 2 desc-loop-length + 0 descriptors + 4 CRC.
646            let prefix = default_prefix(CMD_SPLICE_NULL);
647            let original = build_section(&prefix, &[], &[]);
648
649            // Sanity: the unmutated section must parse cleanly. If
650            // this ever fails the harness is wrong, not the parser.
651            assert!(
652                parse_splice_info_section(&original).is_ok(),
653                "harness baseline: unmutated section must parse",
654            );
655
656            // Mutate one byte at a deterministic index. byte_index is
657            // clamped into the section length; xor_mask=0 would be a
658            // no-op so the strategy excludes it.
659            let idx = byte_index % original.len();
660            let mut mutated = original.clone();
661            mutated[idx] ^= xor_mask;
662
663            // The mutated section either parses (CRC happens to still
664            // match for this specific bit pattern + the mutation didn't
665            // break a structural field), or fails with a documented
666            // error variant. Panic-freedom is the load-bearing
667            // contract: a SCTE-35 wire from an adversarial publisher
668            // must never crash the parser.
669            match parse_splice_info_section(&mutated) {
670                Ok(_info) => {
671                    // If the parser accepted, the wire must literally
672                    // produce a matching CRC under the same algorithm
673                    // we use for emit. This catches a regression where
674                    // CRC verification is silently disabled: in that
675                    // failure mode, the parser would always Ok() under
676                    // mutation and the CRC check below would catch it.
677                    let body = &mutated[..mutated.len() - 4];
678                    let computed = crc32_mpeg2(body);
679                    let wire = u32::from_be_bytes([
680                        mutated[mutated.len() - 4],
681                        mutated[mutated.len() - 3],
682                        mutated[mutated.len() - 2],
683                        mutated[mutated.len() - 1],
684                    ]);
685                    assert_eq!(
686                        computed, wire,
687                        "if parse accepted, CRC must match: idx={idx} mask={xor_mask:#x}",
688                    );
689                }
690                Err(CodecError::Scte35BadCrc { .. })
691                | Err(CodecError::Scte35Malformed(_))
692                | Err(CodecError::EndOfStream { .. }) => {
693                    // All documented rejection paths.
694                }
695                Err(other) => panic!(
696                    "unexpected error variant for mutated section: {other:?} \
697                     (idx={idx} mask={xor_mask:#x})",
698                ),
699            }
700        }
701    }
702}