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}