diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs index 122a6b4..468fb6d 100644 --- a/src/mux/codec/adts.rs +++ b/src/mux/codec/adts.rs @@ -190,6 +190,33 @@ mod tests { ); } + /// A dropped ADTS frame is dropped BECAUSE its header failed validation, so + /// the very fields a duration would come from (sampling_frequency_index, and + /// the 1024-samples-per-AAC-frame constant applied to it) are the ones known + /// to be untrustworthy. This gate therefore reports the drop's duration as + /// zero rather than deriving a number from a header it has just rejected — + /// the honest answer, and the one the count alongside it must be read with. + /// A nonzero constant here would report silence that was never measured. + #[test] + fn dropped_frames_are_counted_but_their_duration_is_not_invented() { + let mut parser = AdtsParser::new(); + // Three frames whose sampling_frequency_index is a reserved value (13), + // so `adts_verdict` rejects each one. + let mut bad = adts_frame(32); + bad[2] = (bad[2] & 0b1100_0011) | (13 << 2); + for i in 0..3 { + let out = parser.parse(&make_pes(bad.clone(), Some(i * 90_000))); + assert!(out.is_empty(), "an invalid ADTS frame is not emitted"); + } + assert_eq!(parser.dropped_frames(), 3, "every drop is counted"); + assert_eq!( + parser.dropped_duration_ns(), + 0, + "the duration comes from the header that just failed validation, so \ + it is reported as unmeasured rather than guessed" + ); + } + #[test] fn reserved_sample_rate_index_is_dropped() { // sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34. diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs index 1d6f033..37cabf2 100644 --- a/src/mux/codec/dropgate.rs +++ b/src/mux/codec/dropgate.rs @@ -200,6 +200,31 @@ mod tests { assert!(!t.is_poisoned()); } + /// The poison verdict is a RATIO — verified drops against every AU seen — so + /// the kept count is half of it. `does_not_poison_a_mostly_good_track` above + /// records its keeps AFTER the single drop, and `maybe_poison` only runs + /// inside `record_drop`, so the keeps are never in the denominator when the + /// verdict is actually computed: that test passes even with the kept count + /// never incremented. Interleaving them puts the kept count on the critical + /// path, where losing it turns the ratio into "verified drops vs verified + /// drops" — always >50% — and silently discards a healthy track. + #[test] + fn interleaved_keeps_are_in_the_poison_denominator() { + let mut t = DropTally::new("test"); + // 2 kept per 1 dropped, well past the minimum-AU gate: a third of the + // track is undecodable, which is bad but nowhere near the >50% threshold. + for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) { + t.record_kept(); + t.record_kept(); + t.record_drop(0, 1000, 512, "bad"); + assert!( + !t.is_poisoned(), + "33% dropped must never poison, at any point in the run" + ); + } + assert_eq!(t.dropped_frames(), TRACK_VERDICT_MIN_AUS * 3); + } + #[test] fn collateral_drops_never_poison_the_track() { // A TrueHD resync-forward run collaterally drops a long burst of AUs, but diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index 9a8fdf2..fe67874 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -1447,6 +1447,81 @@ mod tests { assert_eq!(h.max_display_mastering_luminance, 10_000_000); } + /// `num_extra_slice_header_bits` (H.265 §7.3.2.3) is a PPS field, and the + /// `slice_reserved_flag[i]` bits it counts sit BETWEEN + /// `slice_pic_parameter_set_id` and `slice_type` in the slice segment header + /// (§7.3.6.1). Every existing fixture used a PPS with the field == 0, so the + /// skip was never exercised: a parser that ignored the field entirely agreed + /// with all of them, and would then read `slice_type` from the wrong bit + /// offset on any real stream that sets it — mislabelling every picture's + /// coding type. + #[test] + fn nonzero_num_extra_slice_header_bits_shifts_the_slice_type_offset() { + use super::super::coding::CodingType; + + // PPS body bits: pps_id ue=0 ('1'), sps_id ue=0 ('1'), + // dependent_slice_segments_enabled_flag 0, output_flag_present_flag 0, + // num_extra_slice_header_bits u(3). + let pps_body = |num_extra: u8| 0b1100_0000u8 | (num_extra << 1); + assert_eq!(pps_body(0), 0xC0, "matches the existing zero-extra fixture"); + assert_eq!(pps_body(3), 0xC6); + + let pps_nal = |num_extra: u8| { + let mut v = hevc_nal_header(NAL_PPS).to_vec(); + v.push(pps_body(num_extra)); + v + }; + for n in 0..8u8 { + assert_eq!( + hevc_num_extra_slice_header_bits(&pps_nal(n)), + Some(n as u32), + "PPS must yield the value it encodes, for every u(3) code point" + ); + } + // A PPS truncated to just its 2-byte NAL header carries no field to read, + // so the answer is absent — never a defaulted zero. + assert_eq!( + hevc_num_extra_slice_header_bits(&hevc_nal_header(NAL_PPS)), + None + ); + + // Slice segment header for a non-IRAP VCL NAL (TRAIL_R, type 1): + // first_slice_segment_in_pic_flag 1, slice_pic_parameter_set_id ue=0 + // ('1'), then THREE reserved bits set to 101 (deliberately not zero, so a + // parser that reads them instead of skipping them cannot agree), then + // slice_type ue(v) = '011' → 2 → I. + let slice_body = 0b1_1_101_011u8; + assert_eq!(slice_body, 0xEB); + + let nal = |t: u8, body: u8| { + let mut v = vec![0x00, 0x00, 0x01]; + v.extend_from_slice(&hevc_nal_header(t)); + v.push(body); + v + }; + let mut data = nal(NAL_PPS, pps_body(3)); + data.extend_from_slice(&nal(1, slice_body)); + let frames = HevcParser::new().parse(&make_pes(data, Some(0))); + assert_eq!(frames.len(), 1); + assert_eq!( + frames[0].coding.expect("PictureInfo").coding_type(), + CodingType::I, + "with num_extra=3 the reserved bits are skipped and slice_type reads 2 (I)" + ); + + // Control: the SAME slice bytes under a PPS declaring num_extra=0 land on + // a different slice_type — proving the PPS field, not the slice bytes, + // decides the offset. + let mut data0 = nal(NAL_PPS, pps_body(0)); + data0.extend_from_slice(&nal(1, slice_body)); + let frames0 = HevcParser::new().parse(&make_pes(data0, Some(0))); + assert_eq!( + frames0[0].coding.expect("PictureInfo").coding_type(), + CodingType::B, + "num_extra=0 reads slice_type from bit 2 instead → 0 (B)" + ); + } + #[test] fn hevc_populates_measured_coding_type_and_source() { use super::super::coding::CodingType; diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 84c1f4e..321a2ed 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -284,6 +284,63 @@ mod tests { } } + /// A codec parser's `codec_private()` feeds `DiscStream::codec_private`, + /// which the MKV muxer turns directly into the track's `CodecPrivate` + /// element (RFC 9559 §5.1.4.1.24): `Some(bytes)` writes an element holding + /// exactly those bytes, `None` omits the element entirely. The two are NOT + /// interchangeable — a zero-length `CodecPrivate` asserts that the codec's + /// initialisation data IS empty, which is not true of any codec, and a + /// one-byte one asserts a config no decoder can parse. + /// + /// The gating parsers (ADTS, MPEG audio, FLAC) and the passthrough parser + /// extract no configuration at all: they validate and forward frames whose + /// configuration is carried in band (ADTS headers, MPEG-1 audio frame + /// headers, FLAC frame headers) or supplied by the source container. Having + /// derived nothing, the only truthful answer they can give is "absent" — + /// any `Some` would be a value they invented. Nothing else in the suite + /// distinguished the two, so each of these impls could have returned a + /// fabricated `Some` and produced a malformed track header unnoticed. + #[test] + fn parsers_that_derive_no_config_report_absent_never_an_empty_codec_private() { + // Codecs whose parsers do no configuration extraction, paired with a + // payload that is a REAL frame of that codec so the gate takes its + // keep-path (a rejected frame proves nothing about the config answer). + let cases: [(Codec, Vec); 5] = [ + // ADTS: syncword FFF1, MPEG-4 AAC-LC, 44.1 kHz, stereo, 7-byte frame. + (Codec::Aac, vec![0xFF, 0xF1, 0x50, 0x80, 0x00, 0xBF, 0xFC]), + // MPEG-1 Layer II, 44.1 kHz, 128 kbit/s, stereo. + (Codec::Mp2, vec![0xFF, 0xFD, 0x70, 0x00, 0x00, 0x00]), + // MPEG-1 Layer III, 44.1 kHz, 128 kbit/s, stereo. + (Codec::Mp3, vec![0xFF, 0xFB, 0x90, 0x00, 0x00, 0x00]), + // Not a FLAC frame sync, so the gate passes it through unvalidated — + // still the keep path, and still no configuration derived. + (Codec::Flac, vec![0x01, 0x02, 0x03, 0x04]), + // Opus rides the all-keyframe passthrough parser. + (Codec::Opus, vec![0x78, 0x01, 0x02, 0x03]), + ]; + + for (codec, payload) in cases { + let mut parser = parser_for_codec(codec, None, false); + assert_eq!( + parser.codec_private(), + None, + "{codec:?}: no config before any frame" + ); + parser.parse(&pes(Some(0), payload.clone())); + parser.parse(&pes(Some(90_000), payload)); + assert_eq!( + parser.codec_private(), + None, + "{codec:?}: this parser derives no config, so it must report the \ + CodecPrivate as ABSENT — an empty or invented Some would be \ + written into the track header as if it were real" + ); + // Flushing at end of stream must not conjure one either. + parser.flush(); + assert_eq!(parser.codec_private(), None, "{codec:?}: after flush"); + } + } + #[test] fn audio_codecs_emit_keyframe_frames() { // PES = frame audio: every frame is independently decodable → keyframe. diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index 1c1fb5b..4b32475 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -183,6 +183,30 @@ mod tests { ); } + /// Same contract as the ADTS gate: an MPEG-audio frame is dropped because + /// its header is invalid, and the header is exactly where a frame duration + /// (samples-per-frame for the layer, divided by the sampling rate) would + /// have to come from — see `mpa_verdict`'s doc comment. So the drop's + /// duration is reported as zero, not derived from rejected fields. + #[test] + fn dropped_frames_are_counted_but_their_duration_is_not_invented() { + let mut parser = MpegAudioParser::new(); + // Reserved layer field (00) — rejected per ISO/IEC 11172-3. + let mut bad = mp3_frame(32); + bad[1] &= !0b0000_0110; + for i in 0..3 { + let out = parser.parse(&make_pes(bad.clone(), Some(i * 90_000))); + assert!(out.is_empty(), "an invalid MPEG-audio frame is not emitted"); + } + assert_eq!(parser.dropped_frames(), 3, "every drop is counted"); + assert_eq!( + parser.dropped_duration_ns(), + 0, + "the duration comes from the header that just failed validation, so \ + it is reported as unmeasured rather than guessed" + ); + } + #[test] fn reserved_version_field_is_dropped() { // version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index e6b6a18..2fbfee1 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -341,6 +341,40 @@ mod tests { assert_eq!(display_set_is_forced(&[]), None); } + /// `observed()` is the probe's "did I actually see any PGS content?" signal. + /// When it is false the track's forced state is UNKNOWN, and the probe leaves + /// whatever flag the disc's own metadata supplied alone; when it is true the + /// probe overwrites that flag with its own verdict. A tracker that always + /// claims to have observed something therefore lets an unread or undecrypted + /// subtitle track — where `is_forced()` is vacuously false — overwrite a + /// correct vendor "forced" flag with "not forced". + #[test] + fn observed_stays_false_until_a_real_display_set_is_seen() { + let mut t = ForcedTracker::new(); + assert!(!t.observed(), "a fresh tracker has seen nothing"); + assert!(!t.is_forced(), "and has no verdict to give"); + + // Blocks that carry no display set must not count as observation: a clear + // PCS (zero composition objects), a non-PCS segment, a truncated PCS, and + // an empty frame. + let mut clear = pcs_display(false); + clear[PCS_NUM_OBJECTS_OFFSET] = 0; + let mut ods = pcs_display(true); + ods[0] = 0x15; + for block in [clear, ods, pcs_display(true)[..15].to_vec(), Vec::new()] { + t.observe(&block); + assert!( + !t.observed(), + "a block with no display set leaves the verdict unknown" + ); + } + + // The first real display set is what flips it. + t.observe(&pcs_display(true)); + assert!(t.observed()); + assert!(t.is_forced(), "the only display set seen was forced"); + } + fn make_pes(data: Vec, pts: Option) -> PesPacket { PesPacket { source: None, diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index f8eda54..0723620 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -752,6 +752,70 @@ mod tests { } } + /// The substream count and the substream-directory size are the two numbers + /// that position `mlp_parity_ok`'s check window over the AU header. Every + /// other fixture in this module uses the degenerate shape — one substream, + /// no extraword — so neither function's real behaviour was ever exercised: + /// a constant answer agreed with all of them, and would then mis-window the + /// parity check on the multi-substream AUs that carry 7.1 and Atmos, judging + /// clean audio corrupt (or corrupt audio clean). + #[test] + fn mlp_num_substreams_is_the_top_nibble_of_major_sync_byte_16() { + // Byte 16 of the major sync: top nibble is num_substreams, bottom nibble + // is a different field, so it must not leak into the answer. + for n in 0..16u8 { + let mut ms = vec![0u8; 17]; + ms[16] = (n << 4) | 0x0F; + assert_eq!( + mlp_num_substreams(&ms), + Some(n), + "num_substreams is the high nibble only" + ); + } + // Real counts: 1 substream for 2.0/5.1 core-only, 4 for the 7.1/Atmos + // layouts this crate has to mux. + let mut ms = vec![0u8; 20]; + ms[16] = 0x40; + assert_eq!(mlp_num_substreams(&ms), Some(4)); + + // A major sync too short to contain byte 16 yields no answer — never a + // defaulted count, which would arm the parity check against garbage. + assert_eq!(mlp_num_substreams(&[0u8; 16]), None); + } + + #[test] + fn mlp_substr_header_size_counts_the_extraword_entries() { + // Directory entry: 2 bytes, plus 2 more when the entry's top bit + // (extraword) is set. Build a 4-substream directory that mixes both. + const HDR: usize = 4; + let mut au = vec![0xAAu8; HDR]; + au.extend_from_slice(&[0x00, 0x11]); // plain → 2 + au.extend_from_slice(&[0x80, 0x22, 0x01, 0x02]); // extraword → 4 + au.extend_from_slice(&[0x00, 0x33]); // plain → 2 + au.extend_from_slice(&[0x80, 0x44, 0x03, 0x04]); // extraword → 4 + au.extend_from_slice(&[0xFFu8; 8]); // payload past the directory + assert_eq!( + mlp_substr_header_size(&au, HDR, 4), + Some(12), + "2 + 4 + 2 + 4" + ); + + // Same AU, fewer declared substreams → only that many entries counted. + assert_eq!(mlp_substr_header_size(&au, HDR, 1), Some(2)); + assert_eq!(mlp_substr_header_size(&au, HDR, 2), Some(6)); + assert_eq!(mlp_substr_header_size(&au, HDR, 0), Some(0)); + + // An all-plain directory is 2 bytes per substream. + let plain = vec![0x00u8; HDR + 8]; + assert_eq!(mlp_substr_header_size(&plain, HDR, 4), Some(8)); + + // A directory that runs past the AU has no answer: the parity window + // would otherwise be placed over bytes that are not there. + let truncated = &au[..HDR + 9]; + assert_eq!(mlp_substr_header_size(truncated, HDR, 4), None); + assert_eq!(mlp_substr_header_size(&plain, HDR, 5), None); + } + fn make_truehd_unit(size_bytes: usize) -> Vec { let words = size_bytes / 2; let mut data = vec![0u8; size_bytes]; @@ -994,6 +1058,15 @@ mod tests { frames.extend(parser.flush()); assert_eq!(frames.len(), 2, "the parity-broken AU is dropped"); assert_eq!(parser.dropped_frames(), 1); + // The drop is a SILENCE GAP, and its length is what the CLI reports as + // lost audio. One TrueHD access unit is 40 samples; at the 48 kHz base + // rate that is 40/48000 s = 833_333 ns. A count without a duration (or a + // duration that ignores the AU rate) understates or invents the loss. + assert_eq!( + parser.dropped_duration_ns(), + 833_333, + "one dropped AU = 40 samples at 48 kHz" + ); } #[test] diff --git a/src/mux/demux_sink.rs b/src/mux/demux_sink.rs index 54a4261..8719444 100644 --- a/src/mux/demux_sink.rs +++ b/src/mux/demux_sink.rs @@ -1413,6 +1413,49 @@ mod tests { assert!(ogm.contains("CHAPTER02NAME=2")); } + /// Chapter names originate on the disc, which is untrusted input, and the + /// sink drops them straight into an XML document (``). Escaping + /// is what keeps a hostile or merely odd name from terminating the element and + /// injecting markup — the document is character data, so XML 1.0 §2.4 requires + /// `&` and `<`, and escaping `>` as well keeps a `]]>` sequence safe too. + /// + /// The order matters as much as the set: `&` must be replaced FIRST, otherwise + /// the ampersands introduced by the `<`/`>` replacements get escaped a second + /// time and `<` renders as the literal text `<` instead of a `<`. + #[test] + fn chapter_names_are_xml_escaped_so_a_disc_cannot_inject_markup() { + let chaps = vec![Chapter { + time_secs: 0.0, + name: " Tom & Jerry <3 >:(".to_string(), + }]; + let xml = chapters_xml(&chaps); + + assert!( + xml.contains( + "</ChapterString><Injected/> \ + Tom & Jerry <3 >:(" + ), + "every metacharacter escaped, and `&` escaped first so nothing is \ + double-escaped; got:\n{xml}" + ); + // The injected element must not survive as markup anywhere in the file. + assert!( + !xml.contains(""), + "a chapter name must not be able to open a new element" + ); + // Exactly one ChapterString element pair — the name did not close it early. + assert_eq!(xml.matches("").count(), 1); + assert_eq!(xml.matches("").count(), 1); + + // A name with no metacharacters passes through byte-identical: escaping + // must not rewrite ordinary text. + let plain = chapters_xml(&[Chapter { + time_secs: 0.0, + name: "Opening Credits".to_string(), + }]); + assert!(plain.contains("Opening Credits")); + } + // ── Timeline continuity ────────────────────────────────────────────────── // // The corrector itself is tested verbatim in `crate::mux::timeline`. Here we diff --git a/src/mux/fmp4/mod.rs b/src/mux/fmp4/mod.rs index f9b4758..fcc0600 100644 --- a/src/mux/fmp4/mod.rs +++ b/src/mux/fmp4/mod.rs @@ -630,6 +630,93 @@ mod tests { assert_eq!(&body[8..12], b"vide", "handler_type must be 'vide'"); } + /// `mdhd` (§8.4.2), `vmhd` (§12.1.2) and `dinf`/`dref` (§8.7.1–2) are all + /// mandatory in a video track's media tree, and each fixes field values a + /// player relies on. None of them is read back by this crate — the init + /// segment is only ever written — so nothing else constrains them. + #[test] + fn mdhd_vmhd_and_dinf_carry_the_values_the_spec_fixes() { + let buf = init_segment(); + let boxes = walk_boxes(&buf); + let (_, moov_start, moov_size) = boxes.iter().find(|(t, _, _)| t == b"moov").unwrap(); + let moov_payload = &buf[moov_start + 8..moov_start + moov_size]; + let trak = child(moov_payload, b"trak").expect("trak"); + let mdia = child(&trak[8..], b"mdia").expect("mdia"); + let minf = child(&mdia[8..], b"minf").expect("minf"); + + // ── mdhd. Version 0 layout: vflags(4) creation(4) modification(4) + // timescale(4) duration(4) language(2) pre_defined(2) = 20 bytes. + let mdhd = child(&mdia[8..], b"mdhd").expect("mdia must carry mdhd"); + let body = &mdhd[8..]; + assert_eq!(body[0], 0, "mdhd version 0 (32-bit times)"); + assert_eq!( + body.len(), + 24, + "version-0 mdhd body: vflags(4) creation(4) modification(4) timescale(4) duration(4) language(2) pre_defined(2)" + ); + let media_ts = u32::from_be_bytes([body[12], body[13], body[14], body[15]]); + assert_ne!( + media_ts, 0, + "every fragment's tfdt is expressed in this timescale; zero is a divide-by-zero" + ); + // The mvhd timescale is the movie clock; this single-track init has one + // clock, so the media timescale must agree with it rather than carry an + // independent copy that can drift. + let mvhd = child(moov_payload, b"mvhd").expect("mvhd"); + let mvhd_body = &mvhd[8..]; + let movie_ts = + u32::from_be_bytes([mvhd_body[12], mvhd_body[13], mvhd_body[14], mvhd_body[15]]); + assert_eq!(media_ts, movie_ts, "media clock must match the movie clock"); + + // language: ISO 639-2/T packed as three 5-bit values, bit 15 = 0 (§8.4.2). + let packed = u16::from_be_bytes([body[20], body[21]]); + assert_eq!(packed & 0x8000, 0, "language pad bit must be 0"); + let lang: String = (0..3) + .map(|i| (((packed >> (10 - 5 * i)) & 0x1F) as u8 + 0x60) as char) + .collect(); + assert_eq!(lang, "und", "unknown language is 'und', not empty or 'eng'"); + + // ── vmhd. §12.1.2 fixes flags to 1 and the stub picks the neutral + // compositing mode: graphicsmode 0 (copy) with a zero opcolor. + let vmhd = child(&minf[8..], b"vmhd").expect("minf must carry vmhd"); + let body = &vmhd[8..]; + assert_eq!(body[0], 0, "vmhd version 0"); + assert_eq!( + u32::from_be_bytes([0, body[1], body[2], body[3]]), + 1, + "vmhd flags must be 1" + ); + assert_eq!(body.len(), 12, "vmhd body: vflags(4) mode(2) opcolor(6)"); + assert_eq!( + u16::from_be_bytes([body[4], body[5]]), + 0, + "graphicsmode copy" + ); + assert_eq!(&body[6..12], &[0u8; 6], "opcolor is black"); + + // ── dinf > dref > 'url ' with flags 1: the media lives in this same + // file/segment, so no external location follows (§8.7.2). + let dinf = child(&minf[8..], b"dinf").expect("minf must carry dinf"); + let dref = child(&dinf[8..], b"dref").expect("dinf must carry dref"); + let body = &dref[8..]; + assert_eq!( + u32::from_be_bytes([body[4], body[5], body[6], body[7]]), + 1, + "dref entry_count" + ); + let url = child(&body[8..], b"url ").expect("dref must carry a 'url ' entry"); + assert_eq!( + u32::from_be_bytes([0, url[9], url[10], url[11]]), + 1, + "url flags=1 means self-contained" + ); + assert_eq!( + url.len(), + 12, + "a self-contained url is header(8) + vflags(4) and no location string" + ); + } + #[test] fn wrap_box_size_includes_header() { // §4.2: a box's size field counts the full box including the 8-byte diff --git a/src/mux/m2ts_mux/mod.rs b/src/mux/m2ts_mux/mod.rs index 20c3244..b8d4a8f 100644 --- a/src/mux/m2ts_mux/mod.rs +++ b/src/mux/m2ts_mux/mod.rs @@ -646,6 +646,64 @@ fn mpegts_crc32(data: &[u8]) -> u32 { mod tests { use super::*; + /// Golden vector for an audio PES header (ISO/IEC 13818-1 §2.4.3.7). + /// + /// Audio and video differ in TWO fields that no downstream check in this + /// module distinguishes: the stream_id, and whether `PES_packet_length` is + /// filled in or left at the unbounded 0x0000 form. A receiver reads the + /// length to find the end of the access unit without scanning for the next + /// start code, so an audio PES that borrowed the video form (or emitted a + /// stub header) still tiles into valid-looking 188-byte packets and only + /// fails inside a decoder. + #[test] + fn audio_pes_header_is_byte_exact_and_bounded_unlike_video() { + let es = [0xDEu8, 0xAD, 0xBE, 0xEF]; + // PTS = 90000 ticks = exactly 1 s at 90 kHz. Encoded across 5 bytes as + // '0010' | PTS[32..30] | marker, PTS[29..15] | marker, PTS[14..0] | marker. + let pes = build_audio_pes(90_000, &es); + assert_eq!( + pes, + vec![ + 0x00, 0x00, 0x01, // packet_start_code_prefix + 0xBD, // stream_id = private_stream_1 (BD audio) + 0x00, 0x0C, // PES_packet_length = 3 flag bytes + 5 PTS + 4 ES + 0x80, // '10' MPEG-2 marker, no scrambling/priority + 0x80, // PTS_DTS_flags = '10' (PTS only) + 0x05, // PES_header_data_length = 5 + 0x21, 0x00, 0x05, 0xBF, 0x21, // PTS 90000 with marker bits + 0xDE, 0xAD, 0xBE, 0xEF, // the access unit, verbatim + ] + ); + + // The video builder over the SAME inputs differs in exactly the two + // fields above — proving neither is incidental. + let vid = build_video_pes(90_000, &es); + assert_eq!(vid[3], 0xE0, "video uses the video stream_id"); + assert_eq!( + &vid[4..6], + &[0x00, 0x00], + "video length is the unbounded form (a video PES can exceed u16)" + ); + assert_eq!(&vid[6..], &pes[6..], "everything after the length matches"); + + // A different PTS must move the timestamp bytes and nothing else. + let later = build_audio_pes(180_000, &es); + assert_eq!(&later[..9], &pes[..9], "header up to the PTS is unchanged"); + assert_ne!(&later[9..14], &pes[9..14], "the PTS bytes change"); + assert_eq!(&later[14..], &es, "the access unit is unchanged"); + + // An access unit too large for the u16 length field falls back to the + // unbounded form rather than writing a truncated (wrapped) length. + let big = vec![0x5Au8; u16::MAX as usize - 7]; + let big_pes = build_audio_pes(90_000, &big); + assert_eq!( + &big_pes[4..6], + &[0x00, 0x00], + "8 + es.len() overflows u16 → unbounded length, never a wrapped one" + ); + assert_eq!(&big_pes[14..], &big[..]); + } + /// All emitted bytes must align to 188-byte packet boundaries and /// every packet must start with `0x47`. fn assert_ts_well_formed(buf: &[u8]) { diff --git a/src/mux/mp4/read.rs b/src/mux/mp4/read.rs index c9d4fa3..ae51a14 100644 --- a/src/mux/mp4/read.rs +++ b/src/mux/mp4/read.rs @@ -1197,6 +1197,398 @@ mod tests { assert_eq!(parse_stts(&stts, MAX_SAMPLE_COUNT), vec![1001, 1001, 1001]); } + // ── Composition offsets (`ctts`) — ISO/IEC 14496-12 §8.6.1.3. + // + // `ctts` is the ONLY place a reordered (B-frame) track's presentation time + // survives the write→read cycle: `stts` carries decode duration and `stss` + // carries sync points, so with the composition table wrong or absent every + // frame's PTS collapses onto its DTS and the picture order is destroyed. + // The writer's builder and this reader's parser must therefore be exact + // inverses, over the SIGNED offsets that make it a version-1 box. + + #[test] + fn ctts_build_and_parse_are_exact_inverses_over_signed_offsets() { + // Includes a negative offset (version 1 only), a repeated run, and a + // value repeated non-adjacently — so a parser that loses the sign, drops + // the run expansion, or returns a constant cannot agree. + let offsets: Vec = vec![0, 2, -1, -1, 0, 3003]; + let boxed = crate::mux::mp4::build_ctts(&offsets); + + assert_eq!(&boxed[4..8], b"ctts", "box type"); + assert_eq!( + boxed[8], 1, + "signed composition offsets require ctts version 1 (§8.6.1.3)" + ); + // Run-length coalescing: the two adjacent -1s share one entry, so the + // six offsets become five runs. Size = 8 hdr + 4 ver/flags + 4 count + 5×8. + assert_eq!( + u32::from_be_bytes(boxed[0..4].try_into().unwrap()) as usize, + 8 + 4 + 4 + 5 * 8, + "equal ADJACENT offsets coalesce into a single run" + ); + assert_eq!( + u32::from_be_bytes(boxed[12..16].try_into().unwrap()), + 5, + "entry_count is the run count, not the sample count" + ); + + // The box header is not part of the parser's input: it takes the payload + // from version/flags onward. + assert_eq!(parse_ctts(&boxed[8..], MAX_SAMPLE_COUNT), offsets); + } + + #[test] + fn b_frame_presentation_order_survives_the_mp4_round_trip() { + // Four samples in DECODE order carrying a classic I-P-B-B reorder: the + // second sample presents last. At 25 fps (timescale 25, one tick per + // frame) the composition offsets are [0, +2, -1, -1] — negative, so this + // exercises the signed version-1 path end to end. + // + // Before this test the round trip asserted sample sizes and keyframe + // flags only, so the entire composition-time chain (VideoTiming::ctts, + // build_ctts, parse_ctts) was unconstrained and a demuxed B-frame title + // could have presented in decode order with nothing noticing. + use crate::disc::{ + Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream, + }; + use crate::mux::mp4::Mp4Sink; + use crate::pes::{PesFrame, Stream as _}; + use std::io::Cursor; + + const FRAME_NS: i64 = 40_000_000; // 25 fps, exact + + let mut t = DiscTitle::empty(); + t.streams = vec![DiscStreamE::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Unknown, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })]; + t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])]; + + // (presentation time, payload byte) in decode order. + let plan: [(i64, u8); 4] = [ + (0, 0x10), // I, presents first + (3 * FRAME_NS, 0x20), // P, presents last + (FRAME_NS, 0x30), // B + (2 * FRAME_NS, 0x40), // B + ]; + + let mut buf = Vec::new(); + { + let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap(); + for (i, &(pts, fill)) in plan.iter().enumerate() { + sink.write(&PesFrame { + track: 0, + pts, + keyframe: i == 0, + data: vec![fill; 64 + i * 8], + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + } + sink.finish().unwrap(); + } + + let mut rd = Mp4Reader::from_reader(Cursor::new(buf), "reorder".into()).unwrap(); + let mut got = Vec::new(); + while let Some(f) = rd.read().unwrap() { + got.push((f.pts, f.data[0])); + } + assert_eq!(got.len(), 4); + + // Frames come back in decode order (sorted by DTS), each still carrying + // the presentation time it was written with — identified by payload, so + // a reordering of the samples themselves cannot be mistaken for success. + assert_eq!( + got, + vec![ + (0, 0x10), + (3 * FRAME_NS, 0x20), + (FRAME_NS, 0x30), + (2 * FRAME_NS, 0x40), + ], + "composition times must survive the write→read cycle" + ); + + // The property that matters to a player: presentation order differs from + // decode order, and sorting by PTS recovers the display sequence. + let mut by_pts = got.clone(); + by_pts.sort_by_key(|&(pts, _)| pts); + assert_eq!( + by_pts.iter().map(|&(_, b)| b).collect::>(), + vec![0x10, 0x30, 0x40, 0x20], + "display order is I,B,B,P — not the decode order I,P,B,B" + ); + } + + /// A track's declared duration must be its real length. `mdhd.duration` is in + /// the MEDIA timescale and `mvhd.duration` in the movie timescale + /// (ISO/IEC 14496-12 §8.2.2, §8.4.2); a player uses them to draw the seek bar + /// and to decide when the title ends, so a zeroed or constant duration makes + /// a correct file unseekable and apparently empty. + #[test] + fn declared_track_duration_equals_frame_count_times_frame_duration() { + use crate::disc::{ + Codec, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStreamE, VideoStream, + }; + use crate::mux::mp4::Mp4Sink; + use crate::pes::{PesFrame, Stream as _}; + use std::io::Cursor; + + const FRAME_NS: i64 = 40_000_000; // 25 fps, exact + const FRAMES: usize = 5; + + let mut t = DiscTitle::empty(); + t.streams = vec![DiscStreamE::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Unknown, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + })]; + t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE])]; + + let mut buf = Vec::new(); + { + let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap(); + for i in 0..FRAMES { + sink.write(&PesFrame { + track: 0, + pts: i as i64 * FRAME_NS, + keyframe: i == 0, + data: vec![0x55u8; 128], + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + } + sink.finish().unwrap(); + } + + let moov = find_box(&buf, b"moov").expect("moov"); + + // mvhd is a version-1 FullBox here: vflags(4) creation(8) modification(8) + // timescale(4) duration(8). + let mvhd = find_box(moov, b"mvhd").expect("mvhd"); + assert_eq!(mvhd[0], 1, "mvhd version 1"); + let movie_ts = be32(mvhd, 20); + let movie_dur = u64::from_be_bytes(mvhd[24..32].try_into().unwrap()); + assert_eq!(movie_ts, 90_000); + assert_eq!( + movie_dur, 18_000, + "5 frames at 25 fps is 0.2 s = 18000 ticks at 90 kHz" + ); + + // mdhd, same version-1 layout, but in the media timescale the writer chose. + let mdhd = find_box( + find_box(find_box(moov, b"trak").expect("trak"), b"mdia").expect("mdia"), + b"mdhd", + ) + .expect("mdhd"); + assert_eq!(mdhd[0], 1, "mdhd version 1"); + let media_ts = be32(mdhd, 20); + let media_dur = u64::from_be_bytes(mdhd[24..32].try_into().unwrap()); + assert_eq!(media_ts, 25, "25 fps snaps to a 25-tick timescale"); + assert_eq!( + media_dur, FRAMES as u64, + "duration is one tick per frame in this timescale" + ); + assert_eq!( + media_dur as f64 / media_ts as f64, + movie_dur as f64 / movie_ts as f64, + "the two declared durations must describe the same wall-clock length" + ); + } + + /// The `moov` tree must carry the boxes ISO/IEC 14496-12 makes mandatory for a + /// playable track, with the field values the spec fixes. These live in this + /// module rather than the writer's because the box-walking helpers + /// ([`find_box`]) are here — asserting through them means the test reads the + /// file the way the demuxer does, instead of re-deriving the layout. + /// + /// Nothing in the demux path needs `tkhd`/`vmhd`/`smhd`/`dinf`, so an empty + /// one of any of them round-trips through this crate unnoticed while making + /// the file unplayable elsewhere. + #[test] + fn moov_tree_carries_the_mandatory_track_header_and_media_boxes() { + use crate::disc::{ + AudioChannels, AudioStream, Codec, DiscTitle, FrameRate, HdrFormat, LabelPurpose, + Resolution, SampleRate, Stream as DiscStreamE, VideoStream, + }; + use crate::mux::mp4::Mp4Sink; + use crate::pes::{PesFrame, Stream as _}; + use std::io::Cursor; + + let mut t = DiscTitle::empty(); + t.streams = vec![ + DiscStreamE::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F25, + hdr: HdrFormat::Sdr, + color_space: crate::disc::ColorSpace::Unknown, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }), + DiscStreamE::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Ac3, + channels: AudioChannels::Surround51, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + }), + ]; + t.codec_privates = vec![Some(vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]), None]; + + let ac3 = vec![ + 0x0B, + 0x77, + 0x00, + 0x00, + 0b00_010110, + 0b01000_000, + 0b111_00_00_1, + 0x00, + 0xFF, + 0xFF, + ]; + + let mut buf = Vec::new(); + { + let mut sink = Mp4Sink::create(Cursor::new(&mut buf), &t).unwrap(); + for i in 0..4i64 { + sink.write(&PesFrame { + track: 0, + pts: i * 40_000_000, + keyframe: i == 0, + data: vec![0x55u8; 128], + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + sink.write(&PesFrame { + track: 1, + pts: i * 32_000_000, + keyframe: true, + data: ac3.clone(), + duration_ns: None, + source: None, + coding: None, + }) + .unwrap(); + } + sink.finish().unwrap(); + } + + let moov = find_box(&buf, b"moov").expect("moov"); + let traks = find_boxes_capped(moov, b"trak", usize::MAX); + assert_eq!(traks.len(), 2, "one video trak + one audio trak"); + + // ── tkhd (§8.3.2). Mandatory in every trak. flags bit 0 = track_enabled; + // a track with flags 0 is ignored by a conforming player. Width/height are + // 16.16 fixed point. + let vid_tkhd = find_box(traks[0], b"tkhd").expect("video tkhd"); + assert_eq!(vid_tkhd[0], 1, "tkhd version 1"); + let flags = u32::from_be_bytes([0, vid_tkhd[1], vid_tkhd[2], vid_tkhd[3]]); + assert_eq!(flags & 0x1, 0x1, "track_enabled must be set"); + assert_eq!(be32(vid_tkhd, 20), 1, "first track_id is 1"); + assert_eq!( + be32(vid_tkhd, 88) >> 16, + 1920, + "tkhd width is 16.16 fixed point" + ); + assert_eq!(be32(vid_tkhd, 92) >> 16, 1080, "tkhd height"); + + let aud_tkhd = find_box(traks[1], b"tkhd").expect("audio tkhd"); + assert_eq!(be32(aud_tkhd, 20), 2, "second track_id is 2"); + assert_eq!( + be16(aud_tkhd, 48), + 0x0100, + "an audio track's tkhd volume is 1.0 (8.8 fixed), not muted" + ); + assert_eq!( + (be32(aud_tkhd, 88), be32(aud_tkhd, 92)), + (0, 0), + "a sound track declares zero visual dimensions" + ); + + // ── minf media headers: vmhd for video, smhd for audio (§12.1.2, §12.2.2). + // Exactly one of them, and never the wrong one for the handler. + let minf = |trak: &[u8]| -> Vec { + find_box(find_box(trak, b"mdia").expect("mdia"), b"minf") + .expect("minf") + .to_vec() + }; + let vid_minf = minf(traks[0]); + let aud_minf = minf(traks[1]); + + let vmhd = find_box(&vid_minf, b"vmhd").expect("video minf must carry vmhd"); + assert!( + find_box(&vid_minf, b"smhd").is_none(), + "a video minf must not carry smhd" + ); + // §12.1.2 fixes vmhd flags to 1. + assert_eq!( + u32::from_be_bytes([0, vmhd[1], vmhd[2], vmhd[3]]), + 1, + "vmhd flags must be 1" + ); + assert_eq!( + (be16(vmhd, 4), &vmhd[6..12]), + (0u16, &[0u8; 6][..]), + "graphicsmode 0 (copy) with a zero opcolor" + ); + + let smhd = find_box(&aud_minf, b"smhd").expect("audio minf must carry smhd"); + assert!( + find_box(&aud_minf, b"vmhd").is_none(), + "an audio minf must not carry vmhd" + ); + assert_eq!(be16(smhd, 4), 0, "smhd balance is centre"); + + // ── dinf > dref > "url " with flags 1 = media is in THIS file (§8.7.2). + // Both tracks need it; a missing/empty dref makes the samples unreachable. + for (name, m) in [("video", &vid_minf), ("audio", &aud_minf)] { + let dinf = find_box(m, b"dinf").unwrap_or_else(|| panic!("{name} dinf")); + let dref = find_box(dinf, b"dref").unwrap_or_else(|| panic!("{name} dref")); + assert_eq!(be32(dref, 4), 1, "{name} dref entry_count"); + let url = find_box(&dref[8..], b"url ").unwrap_or_else(|| panic!("{name} url ")); + // `url ` payload is version(1)+flags(3) only when self-contained. + assert_eq!( + u32::from_be_bytes([0, url[1], url[2], url[3]]), + 1, + "{name} url flags=1 (self-contained), so no external name follows" + ); + assert_eq!( + url.len(), + 4, + "{name} self-contained url carries no location" + ); + } + } + // ── Untrusted-input hardening: a crafted MP4 must never panic or over-allocate. #[test] diff --git a/src/mux/ps.rs b/src/mux/ps.rs index 0723857..7a6b530 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -1370,6 +1370,73 @@ mod tests { assert_eq!(p[0].data, vec![0xDE, 0xAD, 0xBE, 0xEF]); } + /// `is_nav()` exists to separate the ONE unmappable stream a DVD is expected + /// to contain — private_stream_2 (0xBF), the PCI/DSI navigation packs + /// (ISO/IEC 13818-1 Table 2-22) — from every other packet whose `dvd_pid()` + /// comes back `None`, which is an unexpected, possibly-lost real stream. The + /// mux loops use the distinction to choose between a silent tally and a + /// per-packet WARN, so collapsing it to a constant either buries a genuine + /// stream loss in the nav tally, or floods the log with one warning per + /// navigation pack on every DVD ever ripped. + /// + /// The invariant that ties the two together: `is_nav()` may only ever be true + /// where `dvd_pid()` is `None` — a packet that routes to a real track must + /// never be silently classified as navigation. + #[test] + fn only_private_stream_2_is_navigation_and_never_a_routable_stream() { + // Demux a program stream carrying, in order: a navigation pack, MPEG-2 + // video, an AC-3 audio substream, and an MPEG audio stream (unmappable on + // DVD, but NOT navigation). + let mut demuxer = PsDemuxer::new(); + let mut data = Vec::new(); + // private_stream_2: no PES extension, payload follows the 6-byte prefix. + data.extend_from_slice(&[0x00, 0x00, 0x01, 0xBF, 0x00, 0x02, 0x00, 0x01]); + // video 0xE0 + data.extend_from_slice(&[ + 0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x11, 0x22, + ]); + // private_stream_1 with AC-3 sub-stream 0x80 (4 bytes of substream header + // follow the sub-id on DVD). + data.extend_from_slice(&[ + 0x00, 0x00, 0x01, 0xBD, 0x00, 0x0A, 0x80, 0x00, 0x00, 0x80, 0x01, 0x00, 0x03, 0x00, + 0xAA, 0xBB, + ]); + // MPEG audio 0xC0 + data.extend_from_slice(&[ + 0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x33, 0x44, + ]); + data.extend_from_slice(&PROGRAM_END); + + let packets = demuxer.feed(&data); + assert_eq!(packets.len(), 4, "four PES packets demuxed"); + + let nav: Vec = packets + .iter() + .filter(|p| p.is_nav()) + .map(|p| p.stream_id) + .collect(); + assert_eq!( + nav, + vec![0xBF], + "exactly the private_stream_2 pack is navigation" + ); + + for p in &packets { + if p.is_nav() { + assert_eq!( + p.dvd_pid(), + None, + "a navigation pack must not also route to a track" + ); + } + } + // The MPEG-audio packet is equally unroutable on DVD, yet must NOT be + // absorbed into the nav tally — that is the distinction being drawn. + let mpa = packets.iter().find(|p| p.stream_id == 0xC0).unwrap(); + assert_eq!(mpa.dvd_pid(), None, "MPEG audio is unmappable on DVD"); + assert!(!mpa.is_nav(), "...but it is a lost stream, not navigation"); + } + #[test] fn unknown_start_code_is_skipped_not_parsed() { // A start code with an ID outside the known PS-layer set diff --git a/src/mux/resync.rs b/src/mux/resync.rs index df80f3b..8c7e9ca 100644 --- a/src/mux/resync.rs +++ b/src/mux/resync.rs @@ -100,7 +100,13 @@ mod tests { // Gap arrives on the next frame (a P referencing lost data) → drop it // and every inter frame until the next keyframe. assert!(!g.admit(true, true, false), "post-gap P dropped"); + assert!( + g.is_armed(), + "the gate reports itself armed WHILE it is dropping — this is what \ + the consumer reads to know the stream is in a resync hole" + ); assert!(!g.admit(true, false, false), "still dropping (no key yet)"); + assert!(g.is_armed(), "still armed mid-run"); assert!(!g.admit(true, false, false)); assert_eq!(g.dropped_in_run(), 3); // Next keyframe resyncs and is emitted. diff --git a/src/mux/ts.rs b/src/mux/ts.rs index 5316a02..fa11fe6 100644 --- a/src/mux/ts.rs +++ b/src/mux/ts.rs @@ -988,6 +988,65 @@ pub fn scan_streams(data: &[u8]) -> Option> { mod tests { use super::*; + /// The PSI scanner walks a BD-TS buffer one BYTE at a time until it finds a + /// packet boundary, so `is_resync_point` is the only thing standing between + /// it and a stray 0x47 in a TP_extra_header or a payload. Latching onto one + /// puts every subsequent field read 1..191 bytes out of phase, so the PID and + /// PUSI bits it then decodes belong to nothing — the scanner either invents a + /// stream or loses the real PMT. Accepting every offset (the shape a constant + /// `true` takes) guarantees it latches on the first offset it tries. + #[test] + fn resync_requires_a_corroborating_follower_not_just_one_sync_byte() { + const P: usize = BD_SOURCE_PACKET_BYTES; + + // Two well-formed source packets: sync at +4 of each. + let mut two = vec![0u8; 2 * P]; + two[4] = SYNC_BYTE; + two[P + 4] = SYNC_BYTE; + assert!( + is_resync_point(&two, 0), + "a real boundary: sync here and 192 bytes on" + ); + + // No sync byte at all → never a boundary. + two[4] = 0x00; + assert!(!is_resync_point(&two, 0)); + two[4] = SYNC_BYTE; + + // Sync here, but the next 192-spaced position is not a sync byte: this is + // a 0x47 that happens to sit inside a header or payload, not a boundary. + two[P + 4] = 0x00; + assert!( + !is_resync_point(&two, 0), + "an uncorroborated 0x47 must be rejected" + ); + two[P + 4] = SYNC_BYTE; + + // The concrete desync the corroboration prevents: a 0x47 byte sitting in + // the payload of the first packet. Its own "sync" test passes, and the + // scanner must still refuse the offset. + let stray = 60usize; + two[stray + 4] = SYNC_BYTE; + assert!( + !is_resync_point(&two, stray), + "a stray 0x47 inside a payload is not a packet boundary" + ); + // ...while the genuine boundaries either side still are. + assert!(is_resync_point(&two, 0)); + + // A lone trailing packet has no follower to corroborate, so it is accepted + // on its own sync byte — otherwise the last packet of every buffer would + // be unreachable. + let mut one = vec![0u8; P]; + one[4] = SYNC_BYTE; + assert!(is_resync_point(&one, 0), "last packet in the buffer"); + one[4] = 0x00; + assert!( + !is_resync_point(&one, 0), + "...but it still needs its own sync byte" + ); + } + #[test] fn test_parse_timestamp() { // Example: PTS = 0 → encoded as 21 00 01 00 01