Ten lenses over v1.6.4..HEAD, every claim read against the code before it was believed. Seven confirmed; six are here, one is recorded for the next round. All of these are the same family — a failure wearing the shape of success — which is the family that once shipped 9 MB of ciphertext inside a main-movie m2ts at rc=0. A clip whose extents cannot be resolved is now accounted for, in both disc readers. Only `UdfUnrecordedExtent` used to count: every other way `file_extents` can fail — a scratched sector under the clip's ICB (DiscRead), an allocation-descriptor chain that never terminated, a file whose data is embedded rather than extent-mapped — fell through to the ordinary "file absent" path. On Blu-ray that yielded a title advertising its full runtime with a clip's bytes silently missing, because the size and the play-item timing had already counted it. On HD-DVD it was worse: the clip was never added to `unusable`, so a split feature still composed from FEATURE_1 alone and offered half a movie as the whole thing. Neither emitted a single log line. Absence is still benign — a 2D disc has no .ssif and the extension fallback exists for exactly that. `Halted` is excluded deliberately, and that exclusion is the whole reason the first version of this fix was wrong. Cancellation makes EVERY drive command return `Halted`; classifying it as a disc defect would have dropped each remaining playlist in turn and handed back a truncated title list at success — the same defect, wearing a cancel. `parse_playlist` returns Option and has no channel to propagate a halt, so the existing behaviour is preserved rather than made worse. Propagating it properly is next round's work. Both log sites now emit the error's OWN code instead of a hardcoded 6017. Accounting a scratched disc (E6000) as an authoring hole would send anyone triaging it looking for the wrong thing entirely. AD type 3 is embedded data, not a descriptor list (ECMA-167 4/14.6.8). `read_icb_extents` lumped it in with the reserved values and decoded the file's own CONTENT as (length, LBA) pairs, manufacturing extents out of arbitrary bytes and pointing the reader at unrelated sectors. This same release already taught `read_directory` to honour type 3; this is the file half of that decision. It is an error rather than an empty list, because an empty list reaches the caller as a clip that contributed nothing while its declared duration still counts it — the silent loss pointed the other way. A legally zero-length embedded file still returns an empty list. New code E6018: reusing DiscRead would have mislabelled a deterministic structural property as transient I/O and fed the retry and NonTrimmed machinery a byte that will never change. `file_extents_addressing`, `extents_abs_at` and `AbsExtent` drop to `pub(crate)`. The first hands back unrecorded extents UNFLAGGED, in a shape identical to the safe call's return; its doc says callers must use `file_extents` instead, but a doc comment is not a guard. No dependent crate references any of the three. Three tests close gaps the audit found, each proven red before green: a held AC-3 access unit must not resume as a normal frame after its track poisons; the PS resume cursor must survive a drain that rebases it (three separate mutants caught); and AD type 3 must be refused rather than decoded. The first attempt at the HD-DVD test passed with the fix reverted, which made it worthless — it needed a VTI fixture before the composition path ran at all. Also: four error codes were missing from the uniqueness test that claims to cover every published code, so a new variant reusing 6014, 6016 or 6017 would have passed it.
2098 lines
90 KiB
Rust
2098 lines
90 KiB
Rust
//! MPEG-2 Program Stream (PS) demuxer.
|
|
//!
|
|
//! DVDs use MPEG-2 Program Stream, which has:
|
|
//! - Pack headers (00 00 01 BA) with SCR timestamps
|
|
//! - PES packets (00 00 01 [stream_id]) with variable length
|
|
//! - System headers (00 00 01 BB)
|
|
//! - Program end code (00 00 01 B9)
|
|
//!
|
|
//! Stream IDs:
|
|
//! - 0xE0-0xEF: video (usually 0xE0)
|
|
//! - 0xC0-0xDF: MPEG audio
|
|
//! - 0xBD: private stream 1 (AC3, DTS, LPCM, subtitles via sub-stream ID)
|
|
|
|
use super::codec::startcode::find_start_code;
|
|
|
|
/// Pack header start code suffix.
|
|
const PACK_HEADER_ID: u8 = 0xBA;
|
|
|
|
/// System header start code suffix.
|
|
const SYSTEM_HEADER_ID: u8 = 0xBB;
|
|
|
|
/// Program end start code suffix.
|
|
const PROGRAM_END_ID: u8 = 0xB9;
|
|
|
|
/// Private stream 1 (AC3, DTS, LPCM, subtitles).
|
|
const PRIVATE_STREAM_1: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_1;
|
|
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
|
/// elementary stream; expected to be dropped on every disc.
|
|
const PRIVATE_STREAM_2: u8 = crate::consts::pes_stream_id::PRIVATE_STREAM_2;
|
|
/// Extended stream id (0xFD) — the H.222.0 escape whereby the real stream id is
|
|
/// the `stream_id_extension` carried in the PES extension. HD-DVD `.evo` puts its
|
|
/// VC-1 video (and HD audio) here (Shaun of the Dead: VC-1 on `0xFD` ext `0x55`);
|
|
/// a transport stream never uses it. The elementary-stream bytes follow the PES
|
|
/// header exactly like any other PES — only the routing key differs.
|
|
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
|
|
|
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
|
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
|
/// an unbounded PES and never follows it with a boundary, `feed()` would
|
|
/// otherwise accumulate the entire input. Past this cap we force the in-progress
|
|
/// unbounded PES to flush at the buffer end so untrusted input cannot drive
|
|
/// unbounded allocation. A real DVD pack/PES is at most a few KB; this leaves
|
|
/// generous slack while still bounding worst-case memory.
|
|
const MAX_PS_BUFFER: usize = 4 * 1024 * 1024;
|
|
|
|
/// A demuxed PES packet from the Program Stream.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PsPacket {
|
|
/// PES stream ID (0xE0 for video, 0xC0 for audio, 0xBD for private, etc.).
|
|
pub stream_id: u8,
|
|
/// Sub-stream ID for private stream 1 (AC3: 0x80-0x87, DTS: 0x88-0x8F,
|
|
/// LPCM: 0xA0-0xA7, subtitles: 0x20-0x3F).
|
|
pub sub_stream_id: Option<u8>,
|
|
/// Presentation timestamp in 90kHz ticks.
|
|
pub pts: Option<u64>,
|
|
/// Decode timestamp in 90kHz ticks.
|
|
pub dts: Option<u64>,
|
|
/// Elementary stream payload data.
|
|
pub data: Vec<u8>,
|
|
/// Source position of this PES's first ES byte, stamped at the demux seam
|
|
/// from the producer's known stream offset. `None` when the demuxer was fed
|
|
/// without a base offset.
|
|
pub source: Option<crate::pes::SourcePos>,
|
|
}
|
|
|
|
/// Canonical DVD video PID. DVD-Video carries a single MPEG-2 video
|
|
/// elementary stream; both the scanner and the muxer use this PID.
|
|
pub const DVD_VIDEO_PID: u16 = 0xE0;
|
|
|
|
/// Canonical PID for a `private_stream_1` audio stream identified by its
|
|
/// on-wire sub-stream id. Returns `None` for sub-ids outside the AC-3 /
|
|
/// DTS / LPCM audio ranges.
|
|
///
|
|
/// The PID is `0xBD00 | sub_stream_id`, which is unique per sub-stream id
|
|
/// (AC-3 / DTS `0x80..=0x8F`, LPCM `0xA0..=0xA7`). Unlike the old
|
|
/// per-codec relative arithmetic, distinct sub-ids therefore always yield
|
|
/// distinct PIDs — so a mixed-codec title (e.g. AC-3 + DTS, whose sub-ids
|
|
/// are 0x80 and 0x88) can never collide on one PID. This is the single
|
|
/// source of truth shared with `Disc::scan_dvd_titles`
|
|
/// (`src/disc/dvd.rs`), which sets each `AudioStream.pid` from the same
|
|
/// function so demuxer output routes through the title's `pid_to_track`.
|
|
///
|
|
/// HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
|
|
/// `private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
|
|
/// (DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
|
|
/// additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
|
|
/// like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
|
|
/// `0xC0..0xC3`) routes each track to its own distinct PID.
|
|
pub fn dvd_audio_pid(sub_stream_id: u8) -> Option<u16> {
|
|
match sub_stream_id {
|
|
0x80..=0x8F | 0xA0..=0xA7 | 0xC0..=0xC7 => Some(0xBD00 | sub_stream_id as u16),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Canonical PID for a VobSub subtitle stream identified by its on-wire
|
|
/// sub-stream id (`0x20..=0x3F`). The PID is the sub-id itself (identity),
|
|
/// which never overlaps the `0xBD..` audio PID space.
|
|
pub fn dvd_subtitle_pid(sub_stream_id: u8) -> Option<u16> {
|
|
match sub_stream_id {
|
|
0x20..=0x3F => Some(sub_stream_id as u16),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Canonical PID for an HD-DVD extended-stream-id (`0xFD`) stream, keyed by its
|
|
/// `stream_id_extension`: `0xFD00 | ext`. Disjoint from the DVD video (`0xE0`) and
|
|
/// `private_stream_1` (`0xBD00..`) PID spaces, so several elementary streams
|
|
/// multiplexed on `0xFD` (VC-1 video, MLP/TrueHD audio) never collide. The
|
|
/// scanner's head probe and `PsPacket::dvd_pid` derive the same PID from the same
|
|
/// `stream_id_extension`, so demux output routes through the title's
|
|
/// `pid_to_track`.
|
|
pub fn hddvd_extended_pid(stream_id_extension: u8) -> u16 {
|
|
0xFD00 | stream_id_extension as u16
|
|
}
|
|
|
|
impl PsPacket {
|
|
/// Map this packet to the canonical DVD PID assigned by
|
|
/// `Disc::scan_dvd_titles` (`src/disc/dvd.rs`), so demux output can
|
|
/// be looked up in the title's `pid_to_track` map.
|
|
///
|
|
/// Routes by the REAL on-wire `(stream_id, sub_stream_id)` via the
|
|
/// shared [`dvd_audio_pid`] / [`dvd_subtitle_pid`] tables the scanner
|
|
/// also uses — never per-codec relative arithmetic, which collided on
|
|
/// mixed-codec audio (AC-3 0x80 and DTS 0x88 both mapping to 0xBD00).
|
|
///
|
|
/// Returns `None` for stream/sub-stream combinations the DVD title
|
|
/// scanner does not assign a PID to (e.g. MPEG audio 0xC0-0xDF,
|
|
/// private stream 2, unrecognized sub-stream ranges). The caller is
|
|
/// expected to WARN-and-drop in that case rather than silently
|
|
/// mis-routing the packet.
|
|
pub fn dvd_pid(&self) -> Option<u16> {
|
|
match self.stream_id {
|
|
crate::consts::pes_stream_id::VIDEO..=0xEF => Some(DVD_VIDEO_PID),
|
|
PRIVATE_STREAM_1 => {
|
|
let sub = self.sub_stream_id?;
|
|
dvd_audio_pid(sub).or_else(|| dvd_subtitle_pid(sub))
|
|
}
|
|
// HD-DVD extended-stream-id (0xFD): route by the stream_id_extension
|
|
// (carried in `sub_stream_id`) to a distinct `0xFD00 | ext` PID, so a
|
|
// disc that puts several elementary streams on 0xFD keeps them apart.
|
|
// The codec (VC-1 etc.) is decided by the scanner's head probe, not
|
|
// here — this only assigns a stable routing key.
|
|
EXTENDED_STREAM_ID => self.sub_stream_id.map(hddvd_extended_pid),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether this is a DVD navigation packet (private_stream_2, 0xBF —
|
|
/// PCI/DSI). These carry no muxable elementary stream and are EXPECTED to
|
|
/// be dropped on every DVD, so a per-packet WARN is noise: the mux loops
|
|
/// count them and emit one finalize summary instead. A `dvd_pid()` of
|
|
/// `None` for any OTHER stream_id is unexpected (a possibly-dropped real
|
|
/// stream) and stays an individual WARN.
|
|
pub fn is_nav(&self) -> bool {
|
|
self.stream_id == PRIVATE_STREAM_2
|
|
}
|
|
}
|
|
|
|
/// MPEG-2 Program Stream demuxer.
|
|
///
|
|
/// Accepts raw PS bytes via `feed()` and produces demuxed PES packets.
|
|
/// Handles non-aligned input by buffering leftover bytes between calls.
|
|
pub struct PsDemuxer {
|
|
buffer: Vec<u8>,
|
|
/// Absolute source byte offset of `buffer[0]` — the running base that turns
|
|
/// an in-buffer unit position into a [`crate::pes::SourcePos`]. Advanced as
|
|
/// the buffer drains. `has_base` gates stamping so non-provenance callers
|
|
/// stay byte-identical.
|
|
buffer_base: u64,
|
|
has_base: bool,
|
|
/// Boundary-scan cursor for an unbounded (length-0) PES still waiting for
|
|
/// its terminating PS-layer unit: `(buffer offset of the PES start code,
|
|
/// buffer offset up to which the search has already proved there is no
|
|
/// boundary)`. Both are buffer-relative and are rebased when the buffer
|
|
/// drains.
|
|
///
|
|
/// Without it, every `feed` re-searches the WHOLE accumulated payload from
|
|
/// the PES header: the buffer only stops growing at [`MAX_PS_BUFFER`], so a
|
|
/// stream that declares an unbounded PES and then never emits a PS-layer
|
|
/// start code (a corrupt or crafted VOB) makes the demuxer scan up to 4 MiB
|
|
/// per call, quadratic in the bytes fed. Cleared whenever the PES is
|
|
/// emitted, so it can never outlive the packet it describes.
|
|
pending_scan: Option<(usize, usize)>,
|
|
/// Test-only: total bytes examined by `find_ps_boundary`. Pins the cursor
|
|
/// above — the property it exists for is a WORK bound, which no
|
|
/// packet-level assertion can observe.
|
|
#[cfg(test)]
|
|
boundary_bytes_scanned: u64,
|
|
}
|
|
|
|
impl Default for PsDemuxer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl PsDemuxer {
|
|
/// Create a new Program Stream demuxer.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
buffer: Vec::with_capacity(64 * 1024),
|
|
buffer_base: 0,
|
|
has_base: false,
|
|
pending_scan: None,
|
|
#[cfg(test)]
|
|
boundary_bytes_scanned: 0,
|
|
}
|
|
}
|
|
|
|
/// Feed raw MPEG-2 PS bytes, returning any completely parsed PES packets.
|
|
pub fn feed(&mut self, data: &[u8]) -> Vec<PsPacket> {
|
|
self.buffer.extend_from_slice(data);
|
|
self.extract_packets(false)
|
|
}
|
|
|
|
/// Like [`feed`](Self::feed) but records the absolute source byte offset of
|
|
/// `data[0]`, so every PES this call completes is stamped with a
|
|
/// [`crate::pes::SourcePos`]. The provenance-stamping entry point; the
|
|
/// highway calls this with each batch's known source offset. The base must
|
|
/// be the offset of the FIRST byte appended (i.e. of `data[0]`), which lines
|
|
/// up with the current buffer tail.
|
|
pub fn feed_at(&mut self, base_offset: u64, data: &[u8]) -> Vec<PsPacket> {
|
|
if !self.has_base {
|
|
// First base seen: the offset of data[0] is base_offset, and data[0]
|
|
// lands at buffer[buffer.len()], so buffer[0] is base_offset minus
|
|
// the bytes already buffered.
|
|
self.buffer_base = base_offset.saturating_sub(self.buffer.len() as u64);
|
|
self.has_base = true;
|
|
}
|
|
self.buffer.extend_from_slice(data);
|
|
self.extract_packets(false)
|
|
}
|
|
|
|
/// Flush remaining buffered data, returning any final PES packets.
|
|
pub fn flush(&mut self) -> Vec<PsPacket> {
|
|
// At EOF, an unbounded (length 0) PES with no trailing start code is
|
|
// a complete-but-unterminated final packet — emit it rather than
|
|
// dropping the tail of the last frame. Genuinely incomplete packets
|
|
// (a length-bounded PES short of its declared size) are still
|
|
// discarded.
|
|
let packets = self.extract_packets(true);
|
|
self.buffer.clear();
|
|
// The buffer the cursor indexes into is gone.
|
|
self.pending_scan = None;
|
|
packets
|
|
}
|
|
|
|
/// Scan the buffer for complete start-code-delimited units and parse
|
|
/// them. When `flushing` is true, a trailing unbounded PES that has no
|
|
/// following start code is emitted using the rest of the buffer as its
|
|
/// payload (EOF terminates it).
|
|
fn extract_packets(&mut self, flushing: bool) -> Vec<PsPacket> {
|
|
let mut packets = Vec::with_capacity(4);
|
|
let mut pos = 0;
|
|
|
|
while let Some(sc) = find_start_code(&self.buffer, pos) {
|
|
if sc + 3 >= self.buffer.len() {
|
|
// Not enough bytes to read the start code ID.
|
|
break;
|
|
}
|
|
|
|
let code = self.buffer[sc + 3];
|
|
|
|
match code {
|
|
PROGRAM_END_ID => {
|
|
// 00 00 01 B9 — 4 bytes, no payload.
|
|
pos = sc + 4;
|
|
}
|
|
PACK_HEADER_ID => {
|
|
// Pack header: need at least 14 bytes for MPEG-2 pack.
|
|
if sc + 14 > self.buffer.len() {
|
|
break; // wait for more data
|
|
}
|
|
// DVD-Video is always MPEG-2 PS, so every 0xBA is treated
|
|
// as a 14-byte MPEG-2 pack: the low 3 bits of byte 13 are
|
|
// pack_stuffing_length. (An MPEG-1 pack would be 12 bytes
|
|
// with no stuffing field, but DVD never emits one.)
|
|
let stuffing = (self.buffer[sc + 13] & 0x07) as usize;
|
|
let pack_len = 14 + stuffing;
|
|
if sc + pack_len > self.buffer.len() {
|
|
break;
|
|
}
|
|
pos = sc + pack_len;
|
|
}
|
|
SYSTEM_HEADER_ID => {
|
|
// System header: 00 00 01 BB [length:2] ...
|
|
if sc + 6 > self.buffer.len() {
|
|
break;
|
|
}
|
|
let header_len =
|
|
((self.buffer[sc + 4] as usize) << 8) | self.buffer[sc + 5] as usize;
|
|
let total = 6 + header_len;
|
|
if sc + total > self.buffer.len() {
|
|
break;
|
|
}
|
|
pos = sc + total;
|
|
}
|
|
id if is_pes_stream_id(id) => {
|
|
// PES packet: 00 00 01 [stream_id] [length:2] ...
|
|
if sc + 6 > self.buffer.len() {
|
|
break;
|
|
}
|
|
let pes_packet_len =
|
|
((self.buffer[sc + 4] as usize) << 8) | self.buffer[sc + 5] as usize;
|
|
|
|
// Total bytes = 6 (start code + stream_id + length) + pes_packet_len.
|
|
// A length of 0 means unbounded (video streams); in that
|
|
// case the packet runs to the next PS-LAYER boundary (pack /
|
|
// system header / program end / next PES), NOT the next raw
|
|
// start code — the video ES payload is itself full of
|
|
// 00 00 01 xx codes that would otherwise cut the PES short.
|
|
let end = if pes_packet_len == 0 {
|
|
// Resume where the last call stopped searching for
|
|
// THIS PES's terminating unit; anything before that is
|
|
// already proved boundary-free.
|
|
let from = match self.pending_scan {
|
|
Some((pes_at, searched_to)) if pes_at == sc => searched_to,
|
|
_ => sc + 4,
|
|
};
|
|
let (found, searched_to) = find_ps_boundary(&self.buffer, from);
|
|
#[cfg(test)]
|
|
{
|
|
self.boundary_bytes_scanned += searched_to.saturating_sub(from) as u64;
|
|
}
|
|
match found {
|
|
Some(next) => {
|
|
self.pending_scan = None;
|
|
next
|
|
}
|
|
// At EOF the rest of the buffer is this PES's
|
|
// payload — emit it.
|
|
None if flushing => {
|
|
self.pending_scan = None;
|
|
self.buffer.len()
|
|
}
|
|
None => {
|
|
// No boundary buffered yet. Normally wait for
|
|
// more data, but a corrupt stream could declare
|
|
// an unbounded PES followed by endless non-
|
|
// boundary bytes — bounding the buffer here
|
|
// stops untrusted input forcing unbounded
|
|
// allocation. Past the cap, flush what we have.
|
|
if self.buffer.len() - sc > MAX_PS_BUFFER {
|
|
self.pending_scan = None;
|
|
self.buffer.len()
|
|
} else {
|
|
self.pending_scan = Some((sc, searched_to));
|
|
break; // wait for more data
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let e = sc + 6 + pes_packet_len;
|
|
if e > self.buffer.len() {
|
|
break; // wait for more data
|
|
}
|
|
e
|
|
};
|
|
|
|
if let Some(mut pkt) = parse_pes_packet(&self.buffer[sc..end]) {
|
|
if self.has_base {
|
|
pkt.source =
|
|
Some(crate::pes::SourcePos::at_byte(self.buffer_base + sc as u64));
|
|
}
|
|
packets.push(pkt);
|
|
}
|
|
pos = end;
|
|
}
|
|
_ => {
|
|
// Unknown start code — skip past it.
|
|
pos = sc + 4;
|
|
}
|
|
}
|
|
}
|
|
|
|
if pos > 0 {
|
|
self.buffer.drain(..pos);
|
|
// Advance the absolute base past the drained bytes so subsequent
|
|
// units stamp from the correct offset.
|
|
if self.has_base {
|
|
self.buffer_base += pos as u64;
|
|
}
|
|
// The cursor is a BUFFER offset, so it moves with the drain. A
|
|
// pending PES always starts at or after `pos` (the loop broke on
|
|
// it, having already consumed everything before it), so neither
|
|
// component can underflow.
|
|
self.pending_scan = self
|
|
.pending_scan
|
|
.map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos));
|
|
}
|
|
|
|
// Trim a start-code-free tail. Every other exit from the loop above
|
|
// leaves the buffer bounded (a pack, system header or length-bounded
|
|
// PES is at most ~64 KiB; a length-0 PES is force-flushed at
|
|
// MAX_PS_BUFFER), but a buffer that holds no `00 00 01` at all never
|
|
// reaches any of those branches: `find_start_code` returns None, `pos`
|
|
// stays 0 and nothing drains. Input that never contains a start code —
|
|
// a zero-filled VOB extent, or an AACS-encrypted clip probed as
|
|
// ciphertext — would then grow the buffer to the size of the whole
|
|
// title. Nothing in such a buffer can begin a PS unit except a 2-byte
|
|
// `00 00` prefix of a start code straddling the feed boundary, so keep
|
|
// exactly that and drop the rest. Lossless: the retained bytes are the
|
|
// only ones a later feed could complete into a start code.
|
|
if self.buffer.len() > START_CODE_PREFIX_KEEP && find_start_code(&self.buffer, 0).is_none()
|
|
{
|
|
let drop = self.buffer.len() - START_CODE_PREFIX_KEEP;
|
|
self.buffer.drain(..drop);
|
|
if self.has_base {
|
|
self.buffer_base += drop as u64;
|
|
}
|
|
// A pending PES implies a start code IS in the buffer, so this
|
|
// branch cannot run while one is open; drop the cursor anyway
|
|
// rather than leave a stale offset behind this drain.
|
|
self.pending_scan = None;
|
|
}
|
|
|
|
packets
|
|
}
|
|
}
|
|
|
|
/// Bytes retained when the buffer holds no start code: a `00 00 01` prefix can
|
|
/// straddle a feed boundary by at most its first two bytes.
|
|
const START_CODE_PREFIX_KEEP: usize = 2;
|
|
|
|
/// Find the next PS-layer unit boundary at or after `from`: a start code whose
|
|
/// ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a
|
|
/// payload-carrying PES stream ID (0xBD..=0xEF).
|
|
///
|
|
/// A length-0 (unbounded) video PES must be delimited by the next PS-layer unit
|
|
/// — NOT by the next raw `00 00 01`. The MPEG-2 video elementary stream inside
|
|
/// the PES is itself full of `00 00 01 xx` start codes (picture 0x00, slices
|
|
/// 0x01..=0xAF, GOP 0xB8, sequence 0xB3); a plain start-code scan would cut the
|
|
/// PES inside its own payload and re-scan the discarded video bytes as bogus PS
|
|
/// units. Restricting the search to PS-layer IDs (>= 0xB9, excluding the video
|
|
/// ES codes below it) frames the unbounded PES at the right boundary.
|
|
/// Returns `(boundary, searched_to)`. `searched_to` is the offset up to which
|
|
/// every byte has been PROVED not to begin a PS-layer boundary start code, so
|
|
/// a later call over the same buffer (grown at the tail) may resume there
|
|
/// instead of re-scanning the payload from the PES header. When the scan runs
|
|
/// off the end, the last two bytes are NOT proved: a `00 00 01` prefix can
|
|
/// straddle the next feed's boundary by up to two bytes.
|
|
fn find_ps_boundary(data: &[u8], from: usize) -> (Option<usize>, usize) {
|
|
let mut pos = from;
|
|
while let Some(sc) = find_start_code(data, pos) {
|
|
if sc + 3 >= data.len() {
|
|
// A start code whose ID byte has not arrived yet: undecided, so
|
|
// the next scan must look at it again.
|
|
return (None, sc);
|
|
}
|
|
let id = data[sc + 3];
|
|
if id == PACK_HEADER_ID
|
|
|| id == SYSTEM_HEADER_ID
|
|
|| id == PROGRAM_END_ID
|
|
|| is_pes_stream_id(id)
|
|
{
|
|
return (Some(sc), sc);
|
|
}
|
|
pos = sc + 4;
|
|
}
|
|
(None, data.len().saturating_sub(2).max(from))
|
|
}
|
|
|
|
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
|
fn is_pes_stream_id(id: u8) -> bool {
|
|
// Video: 0xE0-0xEF, MPEG audio: 0xC0-0xDF, private stream 1: 0xBD,
|
|
// private stream 2: 0xBF, padding: 0xBE, ECM/EMM etc. — plus the HD-DVD
|
|
// extended-stream-id (0xFD), which carries VC-1 video / HD audio.
|
|
crate::consts::pes_stream_id::PAYLOAD_RANGE.contains(&id) || id == EXTENDED_STREAM_ID
|
|
}
|
|
|
|
/// For an extended-stream-id (`0xFD`) PES, walk the optional PES-header fields to
|
|
/// the PES extension and read the 7-bit `stream_id_extension` — the real stream
|
|
/// id. `data` starts at the PES start code; the optional fields live in
|
|
/// `data[9..header_end]` (all bounds-checked against `header_end`). Returns `None`
|
|
/// if the extension is absent or malformed.
|
|
fn parse_stream_id_extension(data: &[u8], flags2: u8, header_end: usize) -> Option<u8> {
|
|
let get = |p: usize| -> Option<u8> {
|
|
if p < header_end {
|
|
data.get(p).copied()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
let mut pos = 9usize;
|
|
let pts_dts = (flags2 >> 6) & 0x03;
|
|
if pts_dts & 0x02 != 0 {
|
|
pos += 5; // PTS
|
|
}
|
|
if pts_dts == 0x03 {
|
|
pos += 5; // DTS
|
|
}
|
|
if flags2 & 0x20 != 0 {
|
|
pos += 6; // ESCR
|
|
}
|
|
if flags2 & 0x10 != 0 {
|
|
pos += 3; // ES_rate
|
|
}
|
|
if flags2 & 0x08 != 0 {
|
|
pos += 1; // DSM_trick_mode
|
|
}
|
|
if flags2 & 0x04 != 0 {
|
|
pos += 1; // additional_copy_info
|
|
}
|
|
if flags2 & 0x02 != 0 {
|
|
pos += 2; // PES_CRC
|
|
}
|
|
if flags2 & 0x01 == 0 {
|
|
return None; // no PES_extension
|
|
}
|
|
let ext_flags = get(pos)?;
|
|
pos += 1;
|
|
if ext_flags & 0x80 != 0 {
|
|
pos += 16; // PES_private_data
|
|
}
|
|
if ext_flags & 0x40 != 0 {
|
|
// pack_header_field: 1-byte length + that many bytes.
|
|
pos += 1 + get(pos)? as usize;
|
|
}
|
|
if ext_flags & 0x20 != 0 {
|
|
pos += 2; // program_packet_sequence_counter
|
|
}
|
|
if ext_flags & 0x10 != 0 {
|
|
pos += 2; // P-STD_buffer
|
|
}
|
|
if ext_flags & 0x01 == 0 {
|
|
return None; // no PES_extension_flag_2
|
|
}
|
|
// PES_extension_field_length (7 bits, marker in the top bit), then the
|
|
// stream_id_extension byte: present when its top bit (the extension flag) is 0.
|
|
let _field_len = get(pos)? & 0x7F;
|
|
pos += 1;
|
|
let b = get(pos)?;
|
|
(b & 0x80 == 0).then_some(b & 0x7F)
|
|
}
|
|
|
|
/// Parse a single PES packet from a byte slice that starts at the start code.
|
|
fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
|
|
// Minimum: 00 00 01 [id] [len:2] = 6 bytes
|
|
if data.len() < 6 {
|
|
return None;
|
|
}
|
|
if data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
|
|
return None;
|
|
}
|
|
|
|
let stream_id = data[3];
|
|
|
|
// Padding stream — skip entirely.
|
|
if stream_id == crate::consts::pes_stream_id::PADDING_STREAM {
|
|
return None;
|
|
}
|
|
|
|
// Streams without standard PES header extension.
|
|
if stream_id == PRIVATE_STREAM_2 {
|
|
let payload = if data.len() > 6 { &data[6..] } else { &[] };
|
|
return Some(PsPacket {
|
|
stream_id,
|
|
sub_stream_id: None,
|
|
pts: None,
|
|
dts: None,
|
|
data: payload.to_vec(),
|
|
// Stamped by the demuxer (extract_packets) when a source base is
|
|
// threaded; the free function has no absolute offset of its own.
|
|
source: None,
|
|
});
|
|
}
|
|
|
|
// Standard PES header: [6]=flags1, [7]=flags2, [8]=header_data_length
|
|
if data.len() < 9 {
|
|
return None;
|
|
}
|
|
|
|
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
|
let header_data_len = data[8] as usize;
|
|
let header_end = 9 + header_data_len;
|
|
|
|
if header_end > data.len() {
|
|
return None;
|
|
}
|
|
|
|
let mut pts = None;
|
|
let mut dts = None;
|
|
|
|
// The PTS (5 bytes at data[9..14]) and DTS (5 bytes at data[14..19])
|
|
// live INSIDE the PES header, so gate on header_data_len covering them
|
|
// (>=5 for PTS, >=10 for PTS+DTS), not merely on total length. A
|
|
// non-conformant packet that sets the flags but declares a too-short
|
|
// header would otherwise read payload bytes as a bogus timestamp.
|
|
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
|
|
pts = parse_pts(&data[9..14]);
|
|
}
|
|
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
|
|
dts = parse_pts(&data[14..19]);
|
|
}
|
|
|
|
let payload = &data[header_end..];
|
|
|
|
// For private stream 1, the first payload byte is the sub-stream ID,
|
|
// followed by a sub-header whose length depends on the sub-stream type.
|
|
let (sub_stream_id, es_data) = if stream_id == EXTENDED_STREAM_ID {
|
|
// HD-DVD extended-stream-id: the real stream id lives in the
|
|
// stream_id_extension inside the PES extension. There is no leading
|
|
// sub-header byte on the payload (unlike private_stream_1), so the ES
|
|
// is the payload verbatim.
|
|
(
|
|
parse_stream_id_extension(data, data[7], header_end),
|
|
payload.to_vec(),
|
|
)
|
|
} else if stream_id == PRIVATE_STREAM_1 && !payload.is_empty() {
|
|
let sub_id = payload[0];
|
|
let skip = match sub_id {
|
|
0x80..=0x8F => 4, // AC3/DTS: sub_id + frame_count + access_unit_ptr(2)
|
|
// HD-DVD Dolby Digital Plus (E-AC-3): the sub-header is the same
|
|
// 4-byte shape as DVD AC-3 — sub_id + number_of_frames(1) +
|
|
// first_access_unit_pointer(2). Verified empirically on ANCHORMAN
|
|
// EVO: across every 0xC0..=0xC7 packet the 0x0B77 E-AC-3 syncword
|
|
// sits `first_access_unit_pointer` bytes past this 4-byte header
|
|
// (the leading bytes are the tail of the previous frame). Stripping
|
|
// exactly these 4 bytes on EVERY packet yields a clean, continuous
|
|
// E-AC-3 elementary stream that the ac3 parser reassembles across
|
|
// PES boundaries; a shorter skip would splice the sub-header bytes
|
|
// into a straddling frame and corrupt it.
|
|
0xC0..=0xC7 => 4,
|
|
0xA0..=0xA7 => 7, // LPCM: sub_id + frames + ptr(2) + emphasis + quant_freq + channels
|
|
_ => 1,
|
|
};
|
|
let start = skip.min(payload.len());
|
|
(Some(sub_id), payload[start..].to_vec())
|
|
} else {
|
|
(None, payload.to_vec())
|
|
};
|
|
|
|
Some(PsPacket {
|
|
stream_id,
|
|
sub_stream_id,
|
|
pts,
|
|
dts,
|
|
data: es_data,
|
|
// Stamped by the demuxer (extract_packets) when a source base is threaded.
|
|
source: None,
|
|
})
|
|
}
|
|
|
|
/// Parse a 5-byte PTS/DTS timestamp field (33 bits at 90kHz).
|
|
///
|
|
/// Layout (ISO/IEC 13818-1 Table 2-17):
|
|
/// ```text
|
|
/// byte0: [prefix:4][pts 32..30:3][marker:1]
|
|
/// byte1: [pts 29..22:8]
|
|
/// byte2: [pts 21..15:7][marker:1]
|
|
/// byte3: [pts 14..7:8]
|
|
/// byte4: [pts 6..0:7][marker:1]
|
|
/// ```
|
|
fn parse_pts(buf: &[u8]) -> Option<u64> {
|
|
debug_assert!(buf.len() >= 5);
|
|
// Validate the three marker bits (bit 0 of bytes 0, 2, 4) per MPEG-2
|
|
// Systems Table 2-17. A timestamp with a cleared marker is malformed —
|
|
// matching ts.rs::parse_timestamp, reject it rather than decode garbage.
|
|
if (buf[0] & 0x01) == 0 || (buf[2] & 0x01) == 0 || (buf[4] & 0x01) == 0 {
|
|
return None;
|
|
}
|
|
let b0 = buf[0] as u64;
|
|
let b1 = buf[1] as u64;
|
|
let b2 = buf[2] as u64;
|
|
let b3 = buf[3] as u64;
|
|
let b4 = buf[4] as u64;
|
|
|
|
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// --- Pack header detection ---
|
|
|
|
#[test]
|
|
fn detect_pack_header() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// MPEG-2 pack header: 14 bytes, stuffing_length = 0
|
|
let mut pack = vec![
|
|
0x00, 0x00, 0x01, 0xBA, // start code
|
|
0x44, 0x00, 0x04, 0x00, 0x04, 0x01, // SCR (6 bytes)
|
|
0x01, 0x89, 0xC3, // mux_rate (3 bytes)
|
|
0xF8, // stuffing_length = 0 (lower 3 bits)
|
|
];
|
|
|
|
// Follow with a PES packet so we have a delimiter
|
|
pack.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, // video stream
|
|
0x00, 0x08, // length = 8
|
|
0x80, 0x00, 0x00, // flags: no PTS/DTS, header_data_length = 0
|
|
0xAA, 0xBB, 0xCC, 0xDD, 0xEE, // payload (5 bytes)
|
|
]);
|
|
|
|
let packets = demuxer.feed(&pack);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].stream_id, 0xE0);
|
|
assert_eq!(packets[0].data, vec![0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
|
|
}
|
|
|
|
#[test]
|
|
fn pack_header_with_stuffing() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// Pack header with 3 stuffing bytes
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3,
|
|
0xFB, // stuffing_length = 3
|
|
0xFF, 0xFF, 0xFF, // stuffing bytes
|
|
];
|
|
|
|
// Followed by a PES packet
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xC0, // audio stream
|
|
0x00, 0x05, // length = 5
|
|
0x80, 0x00, 0x00, // flags: no PTS, header_data_len=0
|
|
0x11, 0x22, // payload
|
|
]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].stream_id, 0xC0);
|
|
assert_eq!(packets[0].data, vec![0x11, 0x22]);
|
|
}
|
|
|
|
// --- PES header + PTS parsing ---
|
|
|
|
#[test]
|
|
fn pes_header_with_pts() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// PTS = 90000 (1 second at 90kHz)
|
|
// 90000 = 0x15F90
|
|
// bit32=0, bits 29-15 = 0x0002BF, bits 14-0 = 0x1F90
|
|
// byte0: 0010_0_1 = 0x21 ... actually let's encode properly:
|
|
//
|
|
// pts = 90000
|
|
// byte0: (0010 << 4) | ((pts >> 29) & 0x0E) | 1
|
|
// = 0x20 | ((90000 >> 29) & 0x0E) | 1 = 0x20 | 0 | 1 = 0x21
|
|
// byte1: (pts >> 22) & 0xFF = (90000 >> 22) & 0xFF = 0
|
|
// byte2: ((pts >> 14) & 0xFE) | 1 = ((90000 >> 14) & 0xFE) | 1 = (0x0A & 0xFE) | 1 = 0x0B
|
|
// byte3: (pts >> 7) & 0xFF = (90000 >> 7) & 0xFF = (703) & 0xFF = 0xBF
|
|
// byte4: ((pts & 0x7F) << 1) | 1 = ((90000 & 0x7F) << 1) | 1 = (0x10 << 1) | 1 = 0x21
|
|
|
|
let pts_bytes = encode_pts(90000, 0x20);
|
|
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xE0, // video stream
|
|
0x00, 0x0D, // length = 13
|
|
0x80, 0x80, 0x05, // flags: PTS only, header_data_len=5
|
|
];
|
|
data.extend_from_slice(&pts_bytes);
|
|
data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00]); // payload
|
|
|
|
// Add a delimiter
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].stream_id, 0xE0);
|
|
assert_eq!(packets[0].pts, Some(90000));
|
|
assert!(packets[0].dts.is_none());
|
|
assert_eq!(packets[0].data, vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00]);
|
|
}
|
|
|
|
#[test]
|
|
fn pes_header_with_pts_and_dts() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
let pts_bytes = encode_pts(180000, 0x30); // PTS marker = 0x30
|
|
let dts_bytes = encode_pts(90000, 0x10); // DTS marker = 0x10
|
|
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x11, // length = 17
|
|
0x80, 0xC0, 0x0A, // flags: PTS+DTS, header_data_len=10
|
|
];
|
|
data.extend_from_slice(&pts_bytes);
|
|
data.extend_from_slice(&dts_bytes);
|
|
data.extend_from_slice(&[0xCA, 0xFE]); // payload
|
|
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].pts, Some(180000));
|
|
assert_eq!(packets[0].dts, Some(90000));
|
|
}
|
|
|
|
// --- Private stream 1 sub-stream extraction ---
|
|
|
|
#[test]
|
|
fn private_stream_1_ac3_substream() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// AC3 sub-header: sub_id(1) + frame_count(1) + access_unit_ptr(2) = 4 bytes
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
|
0x00, 0x0B, // length = 11
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len=0
|
|
0x80, // sub-stream ID: AC3 stream 0
|
|
0x01, 0x00, 0x02, // frame_count + access_unit_ptr (sub-header bytes)
|
|
0xAA, 0xBB, 0xCC, 0xDD, // AC3 payload
|
|
];
|
|
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].stream_id, 0xBD);
|
|
assert_eq!(packets[0].sub_stream_id, Some(0x80));
|
|
assert_eq!(packets[0].data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
|
|
}
|
|
|
|
#[test]
|
|
fn private_stream_1_dts_substream() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// DTS sub-header: sub_id(1) + frame_count(1) + access_unit_ptr(2) = 4 bytes
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, 0x00, 0x09, // length = 9
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len=0
|
|
0x88, // sub-stream ID: DTS stream 0
|
|
0x01, 0x00, 0x00, // sub-header (frame_count + access_unit_ptr)
|
|
0x11, 0x22,
|
|
];
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].sub_stream_id, Some(0x88));
|
|
assert_eq!(packets[0].data, vec![0x11, 0x22]);
|
|
}
|
|
|
|
#[test]
|
|
fn private_stream_1_subtitle_substream() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, 0x00, 0x06, 0x80, 0x00, 0x00,
|
|
0x20, // sub-stream ID: subtitle stream 0
|
|
0xFF, 0xFE,
|
|
];
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].sub_stream_id, Some(0x20));
|
|
}
|
|
|
|
#[test]
|
|
fn private_stream_1_lpcm_substream() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
// LPCM sub-header: sub_id(1) + frames(1) + ptr(2) + emphasis(1) + quant_freq(1) + channels(1) = 7 bytes
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, 0x00, 0x0C, // length = 12
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len=0
|
|
0xA0, // sub-stream ID: LPCM stream 0
|
|
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // LPCM sub-header (6 bytes after sub_id)
|
|
0x01, 0x02, // LPCM payload
|
|
];
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1);
|
|
assert_eq!(packets[0].sub_stream_id, Some(0xA0));
|
|
assert_eq!(packets[0].data, vec![0x01, 0x02]);
|
|
}
|
|
|
|
// --- Incremental feeding ---
|
|
|
|
#[test]
|
|
fn incremental_feed() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
let mut full = vec![
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x06, // length = 6
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len=0
|
|
0xAA, 0xBB, 0xCC,
|
|
];
|
|
full.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
// Feed in two halves
|
|
let mid = full.len() / 2;
|
|
let p1 = demuxer.feed(&full[..mid]);
|
|
assert!(p1.is_empty(), "first half should not produce packets");
|
|
|
|
let p2 = demuxer.feed(&full[mid..]);
|
|
assert_eq!(p2.len(), 1);
|
|
assert_eq!(p2[0].data, vec![0xAA, 0xBB, 0xCC]);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_emits_trailing_unbounded_video_pes() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
// Unbounded (length 0) video PES with no trailing start code — the
|
|
// common EOF case. feed() must not emit it (awaiting a delimiter),
|
|
// but flush() must emit the tail rather than discarding it.
|
|
let data = vec![
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, // video, length 0 (unbounded)
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
|
0xAA, 0xBB, 0xCC, 0xDD,
|
|
];
|
|
let fed = demuxer.feed(&data);
|
|
assert!(fed.is_empty(), "unbounded PES not emitted until delimited");
|
|
let flushed = demuxer.flush();
|
|
assert_eq!(flushed.len(), 1, "flush emits the trailing PES");
|
|
assert_eq!(flushed[0].stream_id, 0xE0);
|
|
assert_eq!(flushed[0].data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
|
|
}
|
|
|
|
// --- Multiple PES packets ---
|
|
|
|
#[test]
|
|
fn multiple_pes_packets() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
let mut data = Vec::new();
|
|
|
|
// First PES: video
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x11, 0x22,
|
|
]);
|
|
|
|
// Second PES: audio
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x33, 0x44,
|
|
]);
|
|
|
|
// Delimiter
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 2);
|
|
assert_eq!(packets[0].stream_id, 0xE0);
|
|
assert_eq!(packets[1].stream_id, 0xC0);
|
|
}
|
|
|
|
// --- unbounded (length-0) video PES framing ---
|
|
|
|
#[test]
|
|
fn unbounded_video_pes_not_cut_by_embedded_start_codes() {
|
|
// A length-0 video PES whose ES payload contains embedded MPEG start
|
|
// codes (picture 0x00, slice 0x01, GOP 0xB8, sequence 0xB3) must be
|
|
// delimited by the NEXT PS-layer boundary (here a program-end 0xB9),
|
|
// not by the first embedded 00 00 01 inside the payload.
|
|
let mut demuxer = PsDemuxer::new();
|
|
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xE0, // video stream
|
|
0x00, 0x00, // length = 0 (unbounded)
|
|
0x80, 0x00, 0x00, // flags: no PTS, header_data_len = 0
|
|
];
|
|
// ES payload with embedded MPEG-2 start codes.
|
|
let payload = [
|
|
0x00, 0x00, 0x01, 0xB3, // sequence header
|
|
0x11, 0x22, 0x00, 0x00, 0x01, 0x00, // picture start code
|
|
0x33, 0x44, 0x00, 0x00, 0x01, 0x01, // slice
|
|
0x55, 0x66,
|
|
];
|
|
data.extend_from_slice(&payload);
|
|
// PS-layer boundary that closes the unbounded PES.
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(packets.len(), 1, "one PES, not several payload fragments");
|
|
assert_eq!(packets[0].stream_id, 0xE0);
|
|
// The whole ES payload survives — none of it discarded as bogus units.
|
|
assert_eq!(packets[0].data, payload.to_vec());
|
|
}
|
|
|
|
#[test]
|
|
fn unbounded_video_pes_waits_for_boundary() {
|
|
// Without a following PS-layer boundary the unbounded PES is held
|
|
// (waiting for more data), not emitted truncated.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0x00, 0xAA, 0xBB]); // picture SC, no PS boundary
|
|
let packets = demuxer.feed(&data);
|
|
assert!(packets.is_empty(), "no PS boundary yet → hold the PES");
|
|
}
|
|
|
|
#[test]
|
|
fn input_with_no_start_code_at_all_is_bounded() {
|
|
// An extent that never contains a 00 00 01 start code — a zero-filled
|
|
// VOB extent, or an AACS-encrypted clip probed as ciphertext (see
|
|
// src/disc/hddvd.rs) — must not accumulate. The whole-title feed in
|
|
// src/mux/disc.rs would otherwise grow the buffer to the size of the
|
|
// title (up to ~90 GB for UHD).
|
|
let mut demuxer = PsDemuxer::new();
|
|
let chunk = vec![0u8; 1024 * 1024];
|
|
for _ in 0..(MAX_PS_BUFFER / chunk.len() + 8) {
|
|
assert!(demuxer.feed(&chunk).is_empty(), "no start code → no PES");
|
|
}
|
|
assert!(
|
|
demuxer.buffer.len() <= MAX_PS_BUFFER,
|
|
"buffer grew to {} with no start code ever seen (cap {})",
|
|
demuxer.buffer.len(),
|
|
MAX_PS_BUFFER
|
|
);
|
|
// Nothing in a start-code-free buffer can ever begin a unit except a
|
|
// 2-byte 00 00 prefix, so the retained tail is tiny.
|
|
assert!(
|
|
demuxer.buffer.len() <= 2,
|
|
"start-code-free tail retained {} bytes",
|
|
demuxer.buffer.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn start_code_split_across_feeds_still_parses() {
|
|
// The start-code-free trim must keep the 2 bytes that can be the
|
|
// prefix of a start code straddling a feed boundary.
|
|
let mut demuxer = PsDemuxer::new();
|
|
assert!(demuxer.feed(&[0xFF, 0xFF, 0x00, 0x00]).is_empty());
|
|
let mut rest = vec![0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00, 0xAA, 0xBB];
|
|
rest.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // PS-layer boundary
|
|
let packets = demuxer.feed(&rest);
|
|
assert_eq!(packets.len(), 1, "split start code must still be found");
|
|
assert_eq!(packets[0].stream_id, 0xE0);
|
|
assert_eq!(packets[0].data, vec![0xAA, 0xBB]);
|
|
}
|
|
|
|
#[test]
|
|
fn unbounded_video_pes_over_cap_is_force_flushed() {
|
|
// A corrupt stream declaring an unbounded PES followed by endless
|
|
// non-boundary bytes must not grow the buffer without limit.
|
|
// NOTE: this case feeds a real start code first, so it exercises only
|
|
// the in-PES cap; the no-start-code path is covered by
|
|
// `input_with_no_start_code_at_all_is_bounded`.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let header = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
|
let packets = demuxer.feed(&header);
|
|
assert!(packets.is_empty());
|
|
// Feed >MAX_PS_BUFFER of bytes containing no PS-layer boundary.
|
|
let chunk = vec![0x55u8; 1024 * 1024];
|
|
let mut emitted = 0;
|
|
for _ in 0..(MAX_PS_BUFFER / chunk.len() + 4) {
|
|
emitted += demuxer.feed(&chunk).len();
|
|
}
|
|
assert!(
|
|
demuxer.buffer.len() <= MAX_PS_BUFFER + chunk.len(),
|
|
"buffer grew to {} (cap {})",
|
|
demuxer.buffer.len(),
|
|
MAX_PS_BUFFER
|
|
);
|
|
// The force-flush emits the over-long PES rather than accumulating it.
|
|
assert!(emitted >= 1, "over-cap unbounded PES is force-flushed");
|
|
}
|
|
|
|
// --- PTS parsing edge cases ---
|
|
|
|
#[test]
|
|
fn pts_zero() {
|
|
// PTS = 0 encoded
|
|
let pts = parse_pts(&encode_pts(0, 0x20));
|
|
assert_eq!(pts, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn pts_large_value() {
|
|
// Test a large PTS value (close to 33-bit max)
|
|
let val: u64 = (1 << 32) - 1; // 0xFFFFFFFF
|
|
let encoded = encode_pts(val, 0x20);
|
|
let decoded = parse_pts(&encoded);
|
|
assert_eq!(decoded, Some(val));
|
|
}
|
|
|
|
// --- DVD PID mapping (track-routing collision regression) ---
|
|
|
|
fn mk(stream_id: u8, sub: Option<u8>) -> PsPacket {
|
|
PsPacket {
|
|
stream_id,
|
|
sub_stream_id: sub,
|
|
pts: None,
|
|
dts: None,
|
|
data: vec![0xAA],
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_extended_stream_id_extracts_stream_id_extension() {
|
|
// SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension
|
|
// only), header_data_length=3, optional bytes 0x0F/... — build the
|
|
// minimal well-formed variant: ext_flags=0x01 (PES_extension_flag_2),
|
|
// field_len=0x81, stream_id_extension=0x55. Payload is the ES.
|
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
|
let opt = [0x01u8, 0x81, 0x55];
|
|
let es = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
|
let len = (3 + opt.len() + es.len()) as u16;
|
|
pkt.extend_from_slice(&len.to_be_bytes());
|
|
pkt.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
|
|
pkt.extend_from_slice(&opt);
|
|
pkt.extend_from_slice(&es);
|
|
|
|
let parsed = parse_pes_packet(&pkt).expect("parses");
|
|
assert_eq!(parsed.stream_id, EXTENDED_STREAM_ID);
|
|
assert_eq!(
|
|
parsed.sub_stream_id,
|
|
Some(0x55),
|
|
"stream_id_extension extracted from PES extension"
|
|
);
|
|
// ES is the payload verbatim — no leading sub-header byte stripped.
|
|
assert_eq!(parsed.data, es);
|
|
// Routes to the extended-stream-id PID space.
|
|
assert_eq!(parsed.dvd_pid(), Some(hddvd_extended_pid(0x55)));
|
|
assert_eq!(parsed.dvd_pid(), Some(0xFD55));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_extended_stream_id_skips_pts_and_dts_before_the_extension() {
|
|
// The common real case: an AU-opening 0xFD VC-1 video PES carries a PTS
|
|
// (and often DTS) in the optional-header region, which the parser must
|
|
// SKIP (PTS +5, DTS +5) to reach the PES_extension → stream_id_extension.
|
|
// Both branches were previously untested (flags2 there was 0x01, skipping
|
|
// everything), so an off-by-one in the skip would silently misroute video.
|
|
let build = |flags2: u8, skip: usize| {
|
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
|
// optional region: `skip` bytes (PTS/DTS placeholders) then
|
|
// ext_flags=0x01, field_len=0x81, stream_id_extension=0x55.
|
|
let mut opt = vec![0xFFu8; skip];
|
|
opt.extend_from_slice(&[0x01, 0x81, 0x55]);
|
|
let es = [0xDEu8, 0xAD];
|
|
let len = (3 + opt.len() + es.len()) as u16;
|
|
pkt.extend_from_slice(&len.to_be_bytes());
|
|
// flags1=0x80, flags2, header_data_length = optional region length.
|
|
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
|
|
pkt.extend_from_slice(&opt);
|
|
pkt.extend_from_slice(&es);
|
|
pkt
|
|
};
|
|
// PTS present (pts_dts bits = 10 → flags2 0x80) + PES_extension (0x01).
|
|
let pts_only = parse_pes_packet(&build(0x81, 5)).expect("parses");
|
|
assert_eq!(
|
|
pts_only.sub_stream_id,
|
|
Some(0x55),
|
|
"extension found after skipping a 5-byte PTS"
|
|
);
|
|
// PTS+DTS present (pts_dts bits = 11 → flags2 0xC0) + PES_extension.
|
|
let pts_dts = parse_pes_packet(&build(0xC1, 10)).expect("parses");
|
|
assert_eq!(
|
|
pts_dts.sub_stream_id,
|
|
Some(0x55),
|
|
"extension found after skipping a 10-byte PTS+DTS"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_extended_stream_id_without_extension_yields_no_sub_id() {
|
|
// A 0xFD PES that declares no PES_extension (flags2=0x00) can't carry a
|
|
// stream_id_extension → sub_stream_id None, and dvd_pid falls through.
|
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
|
let es = [0x11u8, 0x22];
|
|
let len = (3 + es.len()) as u16;
|
|
pkt.extend_from_slice(&len.to_be_bytes());
|
|
pkt.extend_from_slice(&[0x80, 0x00, 0x00]);
|
|
pkt.extend_from_slice(&es);
|
|
|
|
let parsed = parse_pes_packet(&pkt).expect("parses");
|
|
assert_eq!(parsed.sub_stream_id, None);
|
|
assert_eq!(parsed.dvd_pid(), None);
|
|
assert_eq!(parsed.data, es);
|
|
}
|
|
|
|
#[test]
|
|
fn dvd_pid_matches_scanner_assignment() {
|
|
// Video → 0xE0 (matches dvd.rs VideoStream pid).
|
|
assert_eq!(mk(0xE0, None).dvd_pid(), Some(DVD_VIDEO_PID));
|
|
// PID = 0xBD00 | sub_stream_id — unique per sub-id, no collision.
|
|
assert_eq!(mk(0xBD, Some(0x80)).dvd_pid(), Some(0xBD80)); // AC-3 #0
|
|
assert_eq!(mk(0xBD, Some(0x81)).dvd_pid(), Some(0xBD81)); // AC-3 #1
|
|
assert_eq!(mk(0xBD, Some(0x88)).dvd_pid(), Some(0xBD88)); // DTS #0
|
|
assert_eq!(mk(0xBD, Some(0xA0)).dvd_pid(), Some(0xBDA0)); // LPCM #0
|
|
// VobSub subtitle 0x20/0x21 → 0x20 / 0x21 (identity).
|
|
assert_eq!(mk(0xBD, Some(0x20)).dvd_pid(), Some(0x20));
|
|
assert_eq!(mk(0xBD, Some(0x21)).dvd_pid(), Some(0x21));
|
|
// Unmappable: MPEG audio, private stream 2, bogus sub-id.
|
|
assert_eq!(mk(0xC0, None).dvd_pid(), None);
|
|
assert_eq!(mk(0xBF, None).dvd_pid(), None);
|
|
assert_eq!(mk(0xBD, Some(0x10)).dvd_pid(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_codec_audio_does_not_collide() {
|
|
// The core regression: a title mixing AC-3 (0x80), DTS (0x88) and
|
|
// LPCM (0xA0) audio. The old per-codec relative arithmetic mapped
|
|
// all three to 0xBD00. They must now get distinct PIDs that match
|
|
// what dvd.rs assigns from the same dvd_audio_pid() table.
|
|
let ac3 = mk(0xBD, Some(0x80)).dvd_pid().unwrap();
|
|
let dts = mk(0xBD, Some(0x88)).dvd_pid().unwrap();
|
|
let lpcm = mk(0xBD, Some(0xA0)).dvd_pid().unwrap();
|
|
assert_ne!(ac3, dts, "AC-3 and DTS must not collide");
|
|
assert_ne!(ac3, lpcm, "AC-3 and LPCM must not collide");
|
|
assert_ne!(dts, lpcm, "DTS and LPCM must not collide");
|
|
|
|
// Scanner side uses the same table; build a pid_to_track for a
|
|
// mixed-codec title [video, AC-3, DTS, LPCM, sub] and route every
|
|
// PS packet to its own distinct track.
|
|
let pid_to_track: Vec<(u16, usize)> = vec![
|
|
(DVD_VIDEO_PID, 0),
|
|
(dvd_audio_pid(0x80).unwrap(), 1),
|
|
(dvd_audio_pid(0x88).unwrap(), 2),
|
|
(dvd_audio_pid(0xA0).unwrap(), 3),
|
|
(dvd_subtitle_pid(0x20).unwrap(), 4),
|
|
];
|
|
let route = |p: PsPacket| -> Option<usize> {
|
|
let pid = p.dvd_pid()?;
|
|
pid_to_track
|
|
.iter()
|
|
.find(|(x, _)| *x == pid)
|
|
.map(|(_, t)| *t)
|
|
};
|
|
assert_eq!(route(mk(0xE0, None)), Some(0));
|
|
assert_eq!(route(mk(0xBD, Some(0x80))), Some(1)); // AC-3 → its own track
|
|
assert_eq!(route(mk(0xBD, Some(0x88))), Some(2)); // DTS → its own track
|
|
assert_eq!(route(mk(0xBD, Some(0xA0))), Some(3)); // LPCM → its own track
|
|
assert_eq!(route(mk(0xBD, Some(0x20))), Some(4)); // sub → its own track
|
|
}
|
|
|
|
#[test]
|
|
fn subtitle_does_not_collide_with_audio_track() {
|
|
// Subtitle sub-id 0x20 routes to its own subtitle PID (0x20),
|
|
// distinct from any audio PID (0xBD80+).
|
|
let audio0 = mk(0xBD, Some(0x80)).dvd_pid().unwrap(); // 0xBD80
|
|
let sub0 = mk(0xBD, Some(0x20)).dvd_pid().unwrap(); // 0x20
|
|
assert_ne!(
|
|
audio0, sub0,
|
|
"subtitle sub-id 0x20 must NOT map to the audio PID"
|
|
);
|
|
|
|
let pid_to_track: Vec<(u16, usize)> = vec![
|
|
(DVD_VIDEO_PID, 0),
|
|
(dvd_audio_pid(0x80).unwrap(), 1),
|
|
(dvd_audio_pid(0x81).unwrap(), 2),
|
|
(dvd_subtitle_pid(0x20).unwrap(), 3),
|
|
(dvd_subtitle_pid(0x21).unwrap(), 4),
|
|
];
|
|
let route = |p: PsPacket| -> Option<usize> {
|
|
let pid = p.dvd_pid()?;
|
|
pid_to_track
|
|
.iter()
|
|
.find(|(x, _)| *x == pid)
|
|
.map(|(_, t)| *t)
|
|
};
|
|
assert_eq!(route(mk(0xE0, None)), Some(0));
|
|
assert_eq!(route(mk(0xBD, Some(0x80))), Some(1));
|
|
assert_eq!(route(mk(0xBD, Some(0x81))), Some(2));
|
|
assert_eq!(route(mk(0xBD, Some(0x20))), Some(3)); // sub0 → track 3, NOT 1
|
|
assert_eq!(route(mk(0xBD, Some(0x21))), Some(4)); // sub1 → track 4, NOT 2
|
|
}
|
|
|
|
// --- Helper: encode PTS for tests ---
|
|
|
|
fn encode_pts(pts: u64, marker_prefix: u8) -> [u8; 5] {
|
|
let mut buf = [0u8; 5];
|
|
buf[0] = marker_prefix | (((pts >> 30) as u8) & 0x07) << 1 | 1;
|
|
buf[1] = ((pts >> 22) & 0xFF) as u8;
|
|
buf[2] = (((pts >> 15) & 0x7F) as u8) << 1 | 1;
|
|
buf[3] = ((pts >> 7) & 0xFF) as u8;
|
|
buf[4] = (((pts) & 0x7F) as u8) << 1 | 1;
|
|
buf
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Added hardening tests
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/// Program-end start code (00 00 01 B9) — used as a delimiter so a
|
|
/// bounded or unbounded PES preceding it is fully framed.
|
|
const PROGRAM_END: [u8; 4] = [0x00, 0x00, 0x01, 0xB9];
|
|
|
|
// ── parse_pts: full 33-bit field round trip (ISO 13818-1 Table 2-17) ──
|
|
|
|
#[test]
|
|
fn parse_pts_max_33bit() {
|
|
// The PTS field is exactly 33 bits; 2^33-1 must round-trip — a
|
|
// truncated shift/mask would lose the top bits.
|
|
let max = (1u64 << 33) - 1;
|
|
assert_eq!(parse_pts(&encode_pts(max, 0x20)), Some(max));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_pts_rejects_bad_marker_bits() {
|
|
// A timestamp with any marker bit (bit 0 of bytes 0/2/4) cleared is
|
|
// malformed and must be rejected, matching ts.rs::parse_timestamp.
|
|
let mut buf = encode_pts(90000, 0x20);
|
|
assert!(parse_pts(&buf).is_some());
|
|
buf[0] &= !0x01;
|
|
assert_eq!(parse_pts(&buf), None);
|
|
let mut buf = encode_pts(90000, 0x20);
|
|
buf[2] &= !0x01;
|
|
assert_eq!(parse_pts(&buf), None);
|
|
let mut buf = encode_pts(90000, 0x20);
|
|
buf[4] &= !0x01;
|
|
assert_eq!(parse_pts(&buf), None);
|
|
}
|
|
|
|
// ── pack header (0xBA) framing ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn pack_header_waits_for_full_14_bytes() {
|
|
// A pack header needs 14 bytes (MPEG-2). A buffer with only the
|
|
// start code + a few bytes must NOT advance past it — the demuxer
|
|
// waits for more data rather than misframing.
|
|
let mut demuxer = PsDemuxer::new();
|
|
// 00 00 01 BA then only 6 of the 10 remaining pack bytes.
|
|
let partial = vec![0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01];
|
|
let p = demuxer.feed(&partial);
|
|
assert!(p.is_empty());
|
|
// Now supply the rest of the pack (stuffing=0) plus a PES + delimiter.
|
|
let mut rest = vec![0x01, 0x89, 0xC3, 0xF8]; // mux_rate(3) + stuffing byte
|
|
rest.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0xAB, 0xCD,
|
|
]);
|
|
rest.extend_from_slice(&PROGRAM_END);
|
|
let p2 = demuxer.feed(&rest);
|
|
assert_eq!(p2.len(), 1, "PES after a now-complete pack header parses");
|
|
assert_eq!(p2[0].data, vec![0xAB, 0xCD]);
|
|
}
|
|
|
|
#[test]
|
|
fn pack_header_stuffing_length_consumed() {
|
|
// pack_stuffing_length = low 3 bits of byte 13 (ISO 13818-1
|
|
// §2.5.3.4). The demuxer must skip exactly 14 + stuffing bytes. The
|
|
// stuffing region here holds a DECOY PES start code (00 00 01 E0…);
|
|
// if the stuffing count is under-consumed the scanner would re-sync
|
|
// onto that decoy and emit a bogus PES. Correct skip lands directly
|
|
// on the REAL PES.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3,
|
|
0xFD, // stuffing_length = 5 (low 3 bits of 0xFD = 0b101)
|
|
// 5 stuffing bytes containing a decoy PES start code.
|
|
0x00, 0x00, 0x01, 0xE0, 0xDE,
|
|
];
|
|
// Real PES carries 0x11 0x22; the decoy (if mis-parsed) would carry
|
|
// garbage with a different/short payload.
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x11, 0x22,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1, "exactly the real PES; the decoy was skipped");
|
|
assert_eq!(p[0].data, vec![0x11, 0x22]);
|
|
}
|
|
|
|
// ── system header (0xBB) framing ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn system_header_length_skipped() {
|
|
// System header: 00 00 01 BB [header_length:2] body. The demuxer
|
|
// must skip 6 + header_length bytes (ISO 13818-1 §2.5.3.5), even
|
|
// though the body contains bytes that look like PES IDs.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let body = [0x00, 0x00, 0x01, 0xE0, 0xFF, 0xFF]; // decoy PES-looking bytes
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xBB];
|
|
data.extend_from_slice(&(body.len() as u16).to_be_bytes());
|
|
data.extend_from_slice(&body);
|
|
// Real PES after the system header.
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x33, 0x44,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(
|
|
p.len(),
|
|
1,
|
|
"decoy bytes inside system header not parsed as PES"
|
|
);
|
|
assert_eq!(p[0].stream_id, 0xC0);
|
|
assert_eq!(p[0].data, vec![0x33, 0x44]);
|
|
}
|
|
|
|
#[test]
|
|
fn system_header_waits_for_full_body() {
|
|
// System header declaring a body longer than buffered must not
|
|
// advance — wait for more data.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xBB, 0x00, 0x20]; // len=32
|
|
data.extend_from_slice(&[0xAA; 4]); // only 4 of 32 body bytes
|
|
assert!(demuxer.feed(&data).is_empty());
|
|
}
|
|
|
|
// ── PES length / boundary handling ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn bounded_pes_waits_for_full_declared_length() {
|
|
// A PES with a non-zero PES_packet_length must not be emitted until
|
|
// all 6 + length bytes are buffered — never emit a short frame.
|
|
let mut demuxer = PsDemuxer::new();
|
|
// length = 5 → total 11 bytes, supply only 9.
|
|
let head = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00];
|
|
assert!(demuxer.feed(&head).is_empty());
|
|
// supply the remaining 2 payload bytes.
|
|
let p = demuxer.feed(&[0xEE, 0xFF]);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].data, vec![0xEE, 0xFF]);
|
|
}
|
|
|
|
#[test]
|
|
fn padding_stream_0xbe_is_dropped() {
|
|
// Padding stream (0xBE) carries no ES (ISO 13818-1 Table 2-22) and
|
|
// must produce no PsPacket — only the real PES survives.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xBE, 0x00, 0x04, 0xFF, 0xFF, 0xFF, 0xFF];
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x01, 0x02,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1, "padding stream dropped; only real PES emitted");
|
|
assert_eq!(p[0].stream_id, 0xE0);
|
|
}
|
|
|
|
#[test]
|
|
fn private_stream_2_0xbf_has_no_pes_extension() {
|
|
// private_stream_2 (0xBF) carries no standard PES header extension
|
|
// (ISO 13818-1 Table 2-22): the bytes after the 6-byte prefix are
|
|
// raw payload, NOT flags/header_data_length. No PTS, no sub-stream.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xBF, 0x00, 0x04, 0xDE, 0xAD, 0xBE, 0xEF];
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].stream_id, 0xBF);
|
|
assert_eq!(p[0].pts, None, "0xBF carries no PTS");
|
|
assert_eq!(p[0].sub_stream_id, None);
|
|
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<u8> = 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
|
|
// (e.g. 0xB0, reserved) must be skipped 4 bytes and not derail
|
|
// the following real PES.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xB0]; // unknown/reserved code
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x9A, 0xBC,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].data, vec![0x9A, 0xBC]);
|
|
}
|
|
|
|
// ── private_stream_1 sub-header skip lengths ──────────────────────────
|
|
|
|
#[test]
|
|
fn private_stream_1_unknown_subid_skips_one_byte() {
|
|
// For a private_stream_1 sub-id outside the AC3/DTS/LPCM ranges the
|
|
// skip is 1 (just the sub-id byte). All remaining bytes are ES.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, 0x00, 0x06, 0x80, 0x00, 0x00, //
|
|
0x70, // sub-id outside known ranges → skip 1
|
|
0x55, 0x66,
|
|
];
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].sub_stream_id, Some(0x70));
|
|
assert_eq!(p[0].data, vec![0x55, 0x66], "only sub-id byte skipped");
|
|
}
|
|
|
|
#[test]
|
|
fn private_stream_1_short_payload_does_not_underflow_skip() {
|
|
// If the sub-header skip exceeds the payload length, `skip.min(len)`
|
|
// clamps so ES is empty rather than panicking on an out-of-range
|
|
// slice. AC3 skip is 4 but only 2 payload bytes present.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, 0x00, 0x04, 0x80, 0x00, 0x00, //
|
|
0x80, // AC3 sub-id, skip=4
|
|
0x01, // only 1 byte after sub-id (total payload 2 < skip 4)
|
|
];
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].sub_stream_id, Some(0x80));
|
|
assert!(
|
|
p[0].data.is_empty(),
|
|
"clamped skip yields empty ES, no panic"
|
|
);
|
|
}
|
|
|
|
// ── dvd_audio_pid / dvd_subtitle_pid range boundaries ─────────────────
|
|
|
|
#[test]
|
|
fn dvd_audio_pid_range_boundaries() {
|
|
// AC3/DTS audio sub-ids 0x80..=0x8F and LPCM 0xA0..=0xA7 map to
|
|
// 0xBD00|sub. Just-outside values must return None.
|
|
assert_eq!(dvd_audio_pid(0x80), Some(0xBD80));
|
|
assert_eq!(dvd_audio_pid(0x8F), Some(0xBD8F));
|
|
assert_eq!(dvd_audio_pid(0xA0), Some(0xBDA0));
|
|
assert_eq!(dvd_audio_pid(0xA7), Some(0xBDA7));
|
|
// Boundaries just outside the ranges.
|
|
assert_eq!(dvd_audio_pid(0x7F), None);
|
|
assert_eq!(dvd_audio_pid(0x90), None);
|
|
assert_eq!(dvd_audio_pid(0x9F), None);
|
|
assert_eq!(dvd_audio_pid(0xA8), None);
|
|
}
|
|
|
|
#[test]
|
|
fn hddvd_ddplus_substream_maps_to_bd_pid() {
|
|
// HD-DVD Dolby Digital Plus sub-ids 0xC0..=0xC7 map to 0xBD00|sub,
|
|
// distinct per track and disjoint from the DVD audio space. A DVD never
|
|
// emits these, so the range is purely additive.
|
|
assert_eq!(dvd_audio_pid(0xC0), Some(0xBDC0));
|
|
assert_eq!(dvd_audio_pid(0xC3), Some(0xBDC3));
|
|
assert_eq!(dvd_audio_pid(0xC7), Some(0xBDC7));
|
|
// Just outside the range.
|
|
assert_eq!(dvd_audio_pid(0xBF), None);
|
|
assert_eq!(dvd_audio_pid(0xC8), None);
|
|
// Four DD+ tracks (ANCHORMAN) get four distinct PIDs.
|
|
let pids: Vec<u16> = (0xC0u8..=0xC3).map(|s| dvd_audio_pid(s).unwrap()).collect();
|
|
assert_eq!(pids, vec![0xBDC0, 0xBDC1, 0xBDC2, 0xBDC3]);
|
|
// And route through dvd_pid on a private_stream_1 packet.
|
|
assert_eq!(mk(0xBD, Some(0xC0)).dvd_pid(), Some(0xBDC0));
|
|
assert_eq!(mk(0xBD, Some(0xC3)).dvd_pid(), Some(0xBDC3));
|
|
}
|
|
|
|
#[test]
|
|
fn hddvd_ddplus_pes_strips_4byte_subheader_to_syncword() {
|
|
// A private_stream_1 PES carrying DD+ (sub-id 0xC0) has a 4-byte
|
|
// sub-header (sub_id + num_frames(1) + access_unit_ptr(2)); the demuxer
|
|
// must strip exactly those 4 bytes so es_data begins at the E-AC-3
|
|
// payload — here the 0x0B77 syncword sits right after the sub-header.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xBD, // private stream 1
|
|
0x00, 0x0B, // PES_packet_length = 11 (flags2 + hdl1 + 8 payload)
|
|
0x80, 0x00, 0x00, // no PTS, header_data_len = 0
|
|
0xC0, // sub-stream id: DD+ track 0
|
|
0x01, 0x00, 0x00, // num_frames(1) + access_unit_ptr(2)
|
|
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + payload
|
|
];
|
|
data.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].sub_stream_id, Some(0xC0));
|
|
assert_eq!(
|
|
p[0].data,
|
|
vec![0x0B, 0x77, 0xDE, 0xAD],
|
|
"4-byte DD+ sub-header stripped; es_data starts at the syncword"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dvd_subtitle_pid_range_boundaries() {
|
|
// VobSub subtitle sub-ids 0x20..=0x3F map to the identity PID.
|
|
assert_eq!(dvd_subtitle_pid(0x20), Some(0x20));
|
|
assert_eq!(dvd_subtitle_pid(0x3F), Some(0x3F));
|
|
assert_eq!(dvd_subtitle_pid(0x1F), None);
|
|
assert_eq!(dvd_subtitle_pid(0x40), None);
|
|
}
|
|
|
|
#[test]
|
|
fn dvd_pid_all_video_stream_ids_map_to_video() {
|
|
// ISO 13818-1: 0xE0..=0xEF are all video streams. DVD collapses
|
|
// them onto the single canonical video PID.
|
|
for sid in 0xE0u8..=0xEF {
|
|
assert_eq!(
|
|
mk(sid, None).dvd_pid(),
|
|
Some(DVD_VIDEO_PID),
|
|
"stream_id {sid:#04x} must map to video"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── flushing semantics ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn flush_discards_incomplete_bounded_pes() {
|
|
// A bounded PES short of its declared length is genuinely incomplete
|
|
// and must be DROPPED at flush — not emitted with a truncated payload.
|
|
let mut demuxer = PsDemuxer::new();
|
|
// length=10 but only 2 payload bytes supplied.
|
|
let head = vec![
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x0A, 0x80, 0x00, 0x00, 0xAA, 0xBB,
|
|
];
|
|
assert!(demuxer.feed(&head).is_empty());
|
|
let flushed = demuxer.flush();
|
|
assert!(
|
|
flushed.is_empty(),
|
|
"incomplete bounded PES must not be emitted on flush"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pes_header_data_length_skips_pts_when_flag_unset() {
|
|
// If pts_dts_flags == 0 the 5 "PTS" bytes after the fixed header are
|
|
// ES, not a timestamp. A PES with header_data_length=0 and no PTS
|
|
// flag must surface no PTS and keep all payload bytes.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x06, 0x80, 0x00, 0x00, 0x21, 0x00, 0x01,
|
|
];
|
|
// 0x21 0x00 0x01 look like the start of a PTS field but must NOT be
|
|
// parsed as one (flags2 = 0x00 ⇒ no PTS).
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 1);
|
|
assert_eq!(p[0].pts, None);
|
|
assert_eq!(p[0].data, vec![0x21, 0x00, 0x01]);
|
|
}
|
|
|
|
#[test]
|
|
fn unbounded_video_pes_framed_by_next_pes_not_embedded_audio_code() {
|
|
// An unbounded (length 0) video PES must be delimited by the next
|
|
// PS-layer unit. A following AUDIO PES (0xC0) is a valid boundary,
|
|
// so the video ES must include its embedded 00 00 01 00 picture
|
|
// code but stop at the audio PES start.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
|
let video_payload = [0x11, 0x00, 0x00, 0x01, 0x00, 0x22]; // embedded picture SC
|
|
data.extend_from_slice(&video_payload);
|
|
// Next PS-layer unit: an audio PES (bounded).
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x99, 0x88,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
let p = demuxer.feed(&data);
|
|
assert_eq!(p.len(), 2, "video PES + audio PES");
|
|
assert_eq!(p[0].stream_id, 0xE0);
|
|
assert_eq!(
|
|
p[0].data, video_payload,
|
|
"video ES keeps its embedded start code, stops at the audio PES"
|
|
);
|
|
assert_eq!(p[1].stream_id, 0xC0);
|
|
assert_eq!(p[1].data, vec![0x99, 0x88]);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Mutation-gap hardening (mux-ts pass)
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/// `MAX_PS_BUFFER` is read by its own tests only through the same
|
|
/// symbol, so a mutated arithmetic expression in its definition changes
|
|
/// what the symbol itself evaluates to and every self-referential
|
|
/// assertion still passes. Pin the compiled value against a literal
|
|
/// computed independently.
|
|
#[test]
|
|
fn max_ps_buffer_has_the_documented_value() {
|
|
assert_eq!(MAX_PS_BUFFER, 4 * 1024 * 1024);
|
|
}
|
|
|
|
/// Pack-header framing (`sc + 14 > len`, then `sc + pack_len > len`) must
|
|
/// accept an EXACT fit — the whole pack (mandatory 14 bytes, or with
|
|
/// stuffing) present and not one byte more — rather than waiting for
|
|
/// data that will never come. Both checks are preceded by an unrelated
|
|
/// start code so `sc != 0`: at `sc == 0` a `sc + pack_len` vs.
|
|
/// `sc * pack_len` mutant collapses to the same value (`0`) and the
|
|
/// bound stays unreachable from any input.
|
|
#[test]
|
|
fn pack_header_exact_fit_is_consumed_not_awaited() {
|
|
// Case 1: mandatory 14 bytes, no stuffing, nothing else buffered.
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xB0]; // unknown SC -> sc == 4 below
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3,
|
|
0xF8, // stuffing_length = 0
|
|
]);
|
|
assert!(demuxer.feed(&data).is_empty(), "a pack yields no PES");
|
|
assert!(
|
|
demuxer.buffer.is_empty(),
|
|
"an exact-fit pack (no stuffing) must be fully consumed, not held \
|
|
waiting for bytes that will never arrive"
|
|
);
|
|
|
|
// Case 2: with 3 stuffing bytes — exercises `pack_len = 14 + stuffing`
|
|
// at a non-zero `sc`, where a `+` -> `*` mutation diverges sharply
|
|
// from the correct sum.
|
|
let mut demuxer2 = PsDemuxer::new();
|
|
let mut data2 = vec![0x00, 0x00, 0x01, 0xB0];
|
|
data2.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3,
|
|
0xFB, // stuffing_length = 3
|
|
0xFF, 0xFF, 0xFF,
|
|
]);
|
|
assert!(demuxer2.feed(&data2).is_empty());
|
|
assert!(
|
|
demuxer2.buffer.is_empty(),
|
|
"an exact-fit pack WITH stuffing must be fully consumed"
|
|
);
|
|
}
|
|
|
|
/// System-header framing needs exactly `6 + header_length` bytes
|
|
/// (`sc + 6 > len`, then `sc + total > len`). At `header_length == 0`
|
|
/// both boundaries coincide at `len == 6`, so one buffer exercises both
|
|
/// checks' `>` vs `==`/`>=` mutants at once.
|
|
#[test]
|
|
fn system_header_zero_length_exact_fit_is_consumed_not_awaited() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
let data = vec![0x00, 0x00, 0x01, 0xBB, 0x00, 0x00]; // header_length = 0
|
|
assert!(demuxer.feed(&data).is_empty());
|
|
assert!(
|
|
demuxer.buffer.is_empty(),
|
|
"a zero-length system header, fully present, must not be held awaiting more data"
|
|
);
|
|
}
|
|
|
|
/// `header_len` is a 16-bit big-endian field (`buffer[sc+4] << 8 |
|
|
/// buffer[sc+5]`). A `<<` -> `>>` mutation collapses the high byte to
|
|
/// zero, so any `header_length > 255` is misread as just its low byte —
|
|
/// here 300 (`0x012C`) misread as 44. A start code embedded 50 bytes in
|
|
/// (well inside the true 306-byte unit but exactly where the
|
|
/// mis-parsed 50-byte unit would end) must stay buried in the skipped
|
|
/// body under correct parsing, and surface as a bogus extra PES under
|
|
/// the mutant.
|
|
#[test]
|
|
fn system_header_length_high_byte_is_not_dropped() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xBB, 0x01, 0x2C]; // header_length = 300
|
|
let mut body = vec![0xCCu8; 300];
|
|
// Decoy PES start code at body offset 44 -> absolute offset 50,
|
|
// exactly where a misread length of 44 (0x2C) would resume scanning
|
|
// (6 + 44 == 50).
|
|
let decoy = [
|
|
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x99, 0x99,
|
|
];
|
|
body[44..44 + decoy.len()].copy_from_slice(&decoy);
|
|
data.extend_from_slice(&body);
|
|
// The real PES follows the full (306-byte) system header.
|
|
data.extend_from_slice(&[
|
|
0x00, 0x00, 0x01, 0xC0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x77, 0x88,
|
|
]);
|
|
data.extend_from_slice(&PROGRAM_END);
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(
|
|
packets.len(),
|
|
1,
|
|
"the decoy start code embedded in the system header body must stay \
|
|
buried in the skipped body, not surface as a second PES"
|
|
);
|
|
assert_eq!(packets[0].data, vec![0x77, 0x88]);
|
|
}
|
|
|
|
/// `find_ps_boundary`'s bounds check (`sc + 3 >= data.len()`) must stay
|
|
/// an ADDITION: a `+` -> `-` mutation at `sc == 0` underflows the `usize`
|
|
/// subtraction and panics on a plain 3-byte start code with nothing
|
|
/// after it — exactly the tail a real feed can end on.
|
|
#[test]
|
|
fn find_ps_boundary_handles_a_bare_start_code_at_the_buffer_head() {
|
|
assert_eq!(
|
|
find_ps_boundary(&[0x00, 0x00, 0x01], 0),
|
|
(None, 0),
|
|
"an undecided trailing start code is not proved boundary-free"
|
|
);
|
|
}
|
|
|
|
/// An unbounded (length-0) PES is terminated by the next PS-LAYER unit, and
|
|
/// until one arrives the payload accumulates in the buffer. The search for
|
|
/// that unit must not restart at the PES header on every feed: the buffer
|
|
/// only stops growing at `MAX_PS_BUFFER` (4 MiB), and a feed is one read
|
|
/// batch (at most 510 sectors ≈ 1 MiB, 60 sectors ≈ 120 KiB on an optical
|
|
/// drive), so re-scanning from byte 0 costs work quadratic in the bytes
|
|
/// fed — up to 4 MiB of scanning per call, for as long as a corrupt or
|
|
/// crafted VOB withholds the boundary. A conformant DVD ends every pack
|
|
/// within 2048 bytes and never reaches this state.
|
|
///
|
|
/// Measured directly, because a work bound has no packet-level shadow:
|
|
/// `boundary_bytes_scanned` counts the bytes `find_ps_boundary` examines.
|
|
/// 256 chunks x 4 KiB of boundary-free payload is 1 MiB of input;
|
|
/// re-scanning from the header on every call examines
|
|
/// 4 KiB * 256*257/2 = ~128 MiB.
|
|
///
|
|
/// Mutation: drop the `Some((pes_at, searched_to)) if pes_at == sc` arm so
|
|
/// `from` is always `sc + 4`.
|
|
#[test]
|
|
fn an_unterminated_pes_is_not_rescanned_from_its_header_every_feed() {
|
|
const CHUNKS: usize = 256;
|
|
const CHUNK: usize = 4096;
|
|
|
|
let mut demuxer = PsDemuxer::new();
|
|
// Unbounded PES header (length 0), then payload that carries no start
|
|
// code at all, so no PS-layer boundary is ever found.
|
|
demuxer.feed(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]);
|
|
for _ in 0..CHUNKS {
|
|
assert!(
|
|
demuxer.feed(&[0xFFu8; CHUNK]).is_empty(),
|
|
"no boundary yet, so no PES can be emitted"
|
|
);
|
|
}
|
|
|
|
let fed = (CHUNKS * CHUNK) as u64;
|
|
assert!(
|
|
demuxer.boundary_bytes_scanned <= 2 * fed,
|
|
"boundary search examined {} bytes over {fed} bytes of payload — \
|
|
the scan must advance with the buffer, not restart at the PES header",
|
|
demuxer.boundary_bytes_scanned
|
|
);
|
|
|
|
// ...and the cursor must not have cost correctness: the PES still ends
|
|
// at the pack header that finally arrives, with its whole payload.
|
|
let pack = [
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
PACK_HEADER_ID,
|
|
0x44,
|
|
0x00,
|
|
0x04,
|
|
0x00,
|
|
0x04,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x03,
|
|
0xF8,
|
|
];
|
|
let packets = demuxer.feed(&pack);
|
|
assert_eq!(packets.len(), 1, "the pack header terminates the PES");
|
|
assert_eq!(
|
|
packets[0].data.len(),
|
|
CHUNKS * CHUNK,
|
|
"the whole accumulated payload belongs to the PES"
|
|
);
|
|
}
|
|
|
|
/// The resume cursor is a BUFFER offset, so it must be rebased when the
|
|
/// buffer drains — and this is the only test in which a drain actually
|
|
/// happens while a cursor is live.
|
|
///
|
|
/// Mutations this catches, both halves of
|
|
/// `self.pending_scan.map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos))`
|
|
/// in `extract_packets`:
|
|
/// * `searched_to - pos` -> `searched_to`: the next call resumes `pos`
|
|
/// bytes PAST where the previous scan actually stopped, so that window
|
|
/// is never examined. Here the terminating pack header lands inside it
|
|
/// and is missed outright — the unbounded PES runs on past its real
|
|
/// end, swallowing the following unit. That is a CORRECTNESS failure,
|
|
/// not a slow path, and the assertion on the emitted packet catches it.
|
|
/// * `pes_at - pos` -> `pes_at`: the stale offset no longer equals the
|
|
/// PES's post-drain `sc`, the resume arm stops matching and the search
|
|
/// restarts at the PES header. Caught by `boundary_bytes_scanned`,
|
|
/// which is why the fixture puts 64 KiB of payload in the SAME chunk
|
|
/// that opens the PES: that is exactly the span a restart re-examines,
|
|
/// so the mutant roughly doubles the bytes scanned.
|
|
///
|
|
/// `an_unterminated_pes_is_not_rescanned_from_its_header_every_feed` cannot
|
|
/// reach either: it opens the unbounded PES as the very FIRST bytes of the
|
|
/// very first feed, so nothing ever drains ahead of it, `pos` stays 0 and
|
|
/// the subtraction is a no-op. The comment above it asserts neither
|
|
/// component can underflow; nothing exercised the arithmetic at all.
|
|
///
|
|
/// So this fixture puts COMPLETE PS units — a pack header and a
|
|
/// length-bounded PES — ahead of the unbounded video PES *in the same
|
|
/// chunk*. The loop consumes them, breaks on the unbounded PES, and drains
|
|
/// `pos` bytes with `pending_scan` live: exactly the real DVD shape, where
|
|
/// a video PES opens partway through a read batch.
|
|
#[test]
|
|
fn a_resume_cursor_survives_the_drain_of_units_ahead_of_the_unbounded_pes() {
|
|
// Payload fed in the SAME chunk that opens the PES. Large enough that
|
|
// re-scanning it is unmistakable in `boundary_bytes_scanned`, and it is
|
|
// the exact span the un-rebased `pes_at` mutant re-examines.
|
|
const PAYLOAD: usize = 64 * 1024;
|
|
|
|
// A 14-byte MPEG-2 pack header with pack_stuffing_length 0.
|
|
const PACK: [u8; 14] = [
|
|
0x00,
|
|
0x00,
|
|
0x01,
|
|
PACK_HEADER_ID,
|
|
0x44,
|
|
0x00,
|
|
0x04,
|
|
0x00,
|
|
0x04,
|
|
0x01,
|
|
0x00,
|
|
0x00,
|
|
0x03,
|
|
0xF8,
|
|
];
|
|
// A length-BOUNDED PES — a complete unit, so the loop consumes it and
|
|
// `pos` advances past it before breaking on the unbounded PES.
|
|
const BOUNDED_PES: [u8; 11] = [
|
|
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0xAA, 0xBB,
|
|
];
|
|
// The unbounded (length-0) video PES whose scan must be resumed.
|
|
const OPEN_PES: [u8; 9] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
|
|
// Bytes drained ahead of the unbounded PES on the first feed — the
|
|
// `pos` the cursor must be rebased by.
|
|
const DRAINED: usize = PACK.len() + BOUNDED_PES.len();
|
|
|
|
let mut demuxer = PsDemuxer::new();
|
|
let mut first = PACK.to_vec();
|
|
first.extend_from_slice(&BOUNDED_PES);
|
|
first.extend_from_slice(&OPEN_PES);
|
|
first.extend_from_slice(&[0xFFu8; PAYLOAD]);
|
|
let head = demuxer.feed(&first);
|
|
assert_eq!(
|
|
head.len(),
|
|
1,
|
|
"the bounded PES ahead of the open one is emitted immediately, \
|
|
which is what makes the buffer drain with a cursor live"
|
|
);
|
|
|
|
// The terminating pack arrives at the head of the next feed — i.e.
|
|
// within `DRAINED` bytes of where the previous scan stopped, which is
|
|
// precisely the window an un-rebased `searched_to` skips over.
|
|
assert!(
|
|
PACK.len() <= DRAINED,
|
|
"the terminating pack must fit inside the window a stale \
|
|
`searched_to` would skip, or the mutant survives"
|
|
);
|
|
let packets = demuxer.feed(&PACK);
|
|
assert_eq!(
|
|
packets.len(),
|
|
1,
|
|
"the pack header terminates the open PES; a scan resumed past it \
|
|
never sees it and the PES runs on"
|
|
);
|
|
assert_eq!(
|
|
packets[0].data.len(),
|
|
PAYLOAD,
|
|
"exactly the payload fed belongs to the PES"
|
|
);
|
|
|
|
// Work bound: the payload is proved boundary-free ONCE. Re-scanning it
|
|
// after the drain roughly doubles this.
|
|
assert!(
|
|
demuxer.boundary_bytes_scanned <= (PAYLOAD + 1024) as u64,
|
|
"boundary search examined {} bytes over {PAYLOAD} bytes of payload — \
|
|
a cursor left un-rebased across the drain never matches the PES's \
|
|
new offset, so the scan restarts at the header",
|
|
demuxer.boundary_bytes_scanned
|
|
);
|
|
}
|
|
|
|
/// The boundary-ID check is a 4-way `||`; a mutant that turns the FIRST
|
|
/// `||` into `&&` makes a lone pack-header start code (which can never
|
|
/// also equal `SYSTEM_HEADER_ID`) fail to register as a boundary at all.
|
|
#[test]
|
|
fn find_ps_boundary_recognises_a_lone_pack_header() {
|
|
let data = [0x00, 0x00, 0x01, PACK_HEADER_ID, 0xAA];
|
|
assert_eq!(
|
|
find_ps_boundary(&data, 0),
|
|
(Some(0), 0),
|
|
"a pack header start code alone must register as a PS-layer boundary"
|
|
);
|
|
}
|
|
|
|
/// `parse_stream_id_extension` walks every optional PES-header field
|
|
/// (PTS/DTS, ESCR, ES_rate, DSM_trick_mode, additional_copy_info,
|
|
/// PES_CRC) and every optional PES_extension sub-field (PES_private_data,
|
|
/// pack_header_field, program_packet_sequence_counter, P-STD_buffer)
|
|
/// before reaching `stream_id_extension`. Every one of those skips is a
|
|
/// `pos +=`; a single mutated increment (`-=`/`*=`) misaligns every read
|
|
/// after it. This test arms EVERY optional field at once with a known
|
|
/// byte count, so any single wrong skip anywhere in the chain lands on
|
|
/// the wrong byte and the assertion fails — one test proving the whole
|
|
/// walk, rather than one per field.
|
|
#[test]
|
|
fn parse_stream_id_extension_walks_every_optional_field_to_the_right_offset() {
|
|
// flags2: PTS/DTS absent (00), ESCR/ES_rate/DSM_trick_mode/
|
|
// additional_copy_info/PES_CRC all present, PES_extension present.
|
|
let flags2 = 0x20 | 0x10 | 0x08 | 0x04 | 0x02 | 0x01; // 0x3F
|
|
let mut opt = Vec::new();
|
|
opt.extend_from_slice(&[0u8; 6]); // ESCR
|
|
opt.extend_from_slice(&[0u8; 3]); // ES_rate
|
|
opt.push(0); // DSM_trick_mode
|
|
opt.push(0); // additional_copy_info
|
|
opt.extend_from_slice(&[0u8; 2]); // PES_CRC
|
|
// PES_extension: every optional sub-field present + extension_flag_2.
|
|
let ext_flags = 0x80 | 0x40 | 0x20 | 0x10 | 0x01;
|
|
opt.push(ext_flags);
|
|
opt.extend_from_slice(&[0u8; 16]); // PES_private_data
|
|
opt.push(2); // pack_header_field length
|
|
opt.extend_from_slice(&[0u8; 2]); // pack_header_field data
|
|
opt.extend_from_slice(&[0u8; 2]); // program_packet_sequence_counter
|
|
opt.extend_from_slice(&[0u8; 2]); // P-STD_buffer
|
|
opt.push(0x81); // PES_extension_field_length (marker + 7 bits, value unused)
|
|
opt.push(0x55); // stream_id_extension (top bit clear)
|
|
|
|
let mut pkt = vec![0x00, 0x00, 0x01, EXTENDED_STREAM_ID];
|
|
let es = [0xDEu8, 0xAD];
|
|
let len = (3 + opt.len() + es.len()) as u16;
|
|
pkt.extend_from_slice(&len.to_be_bytes());
|
|
pkt.extend_from_slice(&[0x80, flags2, opt.len() as u8]);
|
|
pkt.extend_from_slice(&opt);
|
|
pkt.extend_from_slice(&es);
|
|
|
|
let parsed = parse_pes_packet(&pkt).expect("parses");
|
|
assert_eq!(
|
|
parsed.sub_stream_id,
|
|
Some(0x55),
|
|
"stream_id_extension reached correctly after walking every optional field"
|
|
);
|
|
assert_eq!(parsed.data, es);
|
|
}
|
|
|
|
/// A length-bounded PES (`pes_packet_len != 0`) must be emitted the
|
|
/// moment its declared length is EXACTLY satisfied by the buffer
|
|
/// (`sc + 6 > len`, then `e = sc + 6 + pes_packet_len; e > len`), not
|
|
/// held back waiting for a byte that will never arrive. Feed nothing
|
|
/// after the packet and don't flush — if the boundary checks were
|
|
/// `>=` instead of `>`, an exact fit would incorrectly be treated as
|
|
/// "not enough data yet" and the packet would never be produced.
|
|
#[test]
|
|
fn length_bounded_pes_exact_fit_is_emitted_not_awaited() {
|
|
let mut demuxer = PsDemuxer::new();
|
|
let payload = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
|
let mut data = vec![0x00, 0x00, 0x01, 0xC0]; // audio stream id
|
|
let pes_packet_len = (3 + payload.len()) as u16; // flags+header_len byte + payload
|
|
data.extend_from_slice(&pes_packet_len.to_be_bytes());
|
|
data.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS/DTS, header_data_len = 0
|
|
data.extend_from_slice(&payload);
|
|
assert_eq!(data.len(), 6 + pes_packet_len as usize, "sanity: exact fit");
|
|
|
|
let packets = demuxer.feed(&data);
|
|
assert_eq!(
|
|
packets.len(),
|
|
1,
|
|
"an exact-fit length-bounded PES must be emitted immediately, \
|
|
not held awaiting a byte that will never come"
|
|
);
|
|
assert_eq!(packets[0].data, payload);
|
|
assert!(
|
|
demuxer.buffer.is_empty(),
|
|
"the exact-fit PES must be fully consumed, leaving nothing buffered"
|
|
);
|
|
}
|
|
}
|