Two audit fixes plus two consistency cleanups, all in the changed 1.6.5 surface: - hddvd: the VTI clip-order read used `.ok()`, flattening an unreadable authored order (a scratched sector under the .vti — the name came from the directory, so it is never "absent") into "no order" with no diagnostic. The sibling clip-extent arms log every read failure with its code; this one now does too, then falls back to the per-clip heuristic exactly as an unauthored disc would. Behaviour is otherwise unchanged; the loud line is the point. - extract: the progress sink hardcoded bytes_unreadable_total: 0 and counted every zero-filled hole as good, so a progress-only consumer saw a holed extraction climb to a clean 100%. Thread the running unreadable total through and report the real good/unreadable split. The authoritative ExtractResult was already truthful; only the live channel lied. - labels: correct a stale comment that described testlog's old lock-based capture; it now installs one global subscriber and routes to a thread-local sink. - ps: import SYSTEM_HEADER from consts instead of re-declaring 0xBB, matching its sibling stream-id constants and the file's single-source rule.
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 = crate::consts::pes_stream_id::SYSTEM_HEADER;
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|