Files
libfreemkv/src/mpls.rs
T
Matthew Jackson fb321f51eb Reject a short READ CAPACITY reply, and count only entry marks as chapters
Two cases of the same shape: one policy implemented twice, with only one
copy hardened.

Disc::read_capacity decoded buf[0..4] from READ CAPACITY (10) without
checking that the transport actually delivered four bytes, even though its
comment claims to mirror decode_read_capacity — which has exactly that
check, and documents why. A drive answering GOOD with an empty data phase
leaves the buffer zeroed, so last_lba decodes to 0 and the probe reports a
one-sector disc instead of an error. It now calls the shared decoder rather
than re-deriving it.

collect_chapter_summary filtered chapters on mark_type <= 1, counting the
reserved type 0. PlaylistMark's own doc says filters must test == 1, and
disc/bluray.rs did; the labels path did not, inflating the public
chapter_count and letting a playlist whose only marks are reserved pass the
chapter_count == 0 skip. Both sites now share PlaylistMark::is_chapter_mark
so the copies cannot drift again.

Both fixes were confirmed red before green.
2026-08-01 11:00:49 -07:00

1734 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! MPLS playlist parser — Blu-ray movie playlists.
//!
//! Each .mpls file in BDMV/PLAYLIST/ defines a title.
//! Contains play items (clips) with in/out timestamps,
//! stream info (video, audio, subtitle tracks).
//!
//! Reference: https://github.com/lw/BluRay/wiki/MPLS
use crate::error::{Error, Result};
/// Parsed MPLS playlist.
#[derive(Debug)]
pub(crate) struct Playlist {
/// MPLS version (e.g. "0200" or "0300"). Parsed for completeness;
/// no production reader yet.
#[allow(dead_code)]
pub version: String,
/// Play items in playback order
pub play_items: Vec<PlayItem>,
/// Streams from the first play item's STN table
pub streams: Vec<StreamEntry>,
/// Playlist marks (chapter points, etc.)
pub marks: Vec<PlaylistMark>,
}
/// A playlist mark entry from the PlayListMark section.
#[derive(Debug, Clone)]
pub(crate) struct PlaylistMark {
/// PlayListMark mark_type (BD-ROM PlayListMark spec):
/// 0 = reserved, 1 = entry mark (chapter), 2 = link point.
/// Chapter filters should test `== 1`, not `<= 1`.
pub mark_type: u8,
/// Which play item this mark belongs to. Carries the per-PlayItem
/// timebase needed to place a mark in a multi-PlayItem playlist;
/// the chapter builder does not consume it yet.
#[allow(dead_code)]
pub play_item_ref: u16,
/// Timestamp in 45kHz PTS ticks
pub timestamp: u32,
}
impl PlaylistMark {
/// Is this mark a chapter entry point?
///
/// Only `mark_type == 1` counts. Type 0 is reserved and type 2 is a link
/// point, and neither is a chapter. Every chapter filter in the crate goes
/// through here: two hand-rolled copies had already drifted, one testing
/// `<= 1` and silently counting reserved marks as chapters.
pub(crate) fn is_chapter_mark(&self) -> bool {
self.mark_type == 1
}
}
/// A play item — one clip reference with in/out times.
#[derive(Debug)]
pub(crate) struct PlayItem {
/// Clip filename without extension (e.g. "00001")
pub clip_id: String,
/// In-time in 45kHz ticks
pub in_time: u32,
/// Out-time in 45kHz ticks
pub out_time: u32,
/// Connection condition (1=seamless, 5/6=non-seamless). Parsed for
/// completeness; no production reader yet.
#[allow(dead_code)]
pub connection_condition: u8,
}
/// A stream entry from the STN table.
#[derive(Debug, Clone)]
pub struct StreamEntry {
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 5=secondary audio,
/// 6=secondary video, 7=DV EL. IG (4) is consumed during parsing to keep
/// the STN cursor aligned but is never retained as a StreamEntry.
pub stream_type: u8,
/// MPEG-TS PID
pub pid: u16,
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
pub coding_type: u8,
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
pub video_format: u8,
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
pub video_rate: u8,
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
pub audio_format: u8,
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
pub audio_rate: u8,
/// ISO 639-2 language code (e.g. "eng")
pub language: String,
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
pub dynamic_range: u8,
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
pub color_space: u8,
/// Whether this is a secondary stream (commentary, PiP, DV EL)
pub secondary: bool,
}
/// Parse an MPLS file from raw bytes.
///
/// `data` is the raw contents of a `BDMV/PLAYLIST/*.mpls` file. Returns
/// [`Error::MplsParse`] on malformed or truncated input.
///
/// Note: [`Playlist::streams`] is extracted ONLY from the first play
/// item's STN table. Multi-item playlists whose later items carry a
/// different codec/track set are not fully represented by `streams`;
/// callers selecting tracks for mux should account for this.
pub fn parse(data: &[u8]) -> Result<Playlist> {
if data.len() < 40 {
return Err(Error::MplsParse);
}
if &data[0..4] != b"MPLS" {
return Err(Error::MplsParse);
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
let mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
if playlist_start + 10 > data.len() {
return Err(Error::MplsParse);
}
let pl = &data[playlist_start..];
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
// num_play_items is an untrusted u16 (max 65535); cap the pre-allocation
// so a truncated/fuzz input can't force a large reservation that the
// bounds-checked loop never fills. 256 covers any realistic playlist.
let mut play_items = Vec::with_capacity(num_play_items.min(256));
let mut streams = Vec::new();
let mut pos = 10;
for item_idx in 0..num_play_items {
if pos + 2 > pl.len() {
break;
}
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
if pos + 2 + item_length > pl.len() {
break;
}
let item = &pl[pos + 2..pos + 2 + item_length];
if item.len() < 20 {
pos += 2 + item_length;
continue;
}
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
let connection_condition = item[9] & 0x0F;
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
// Parse STN table from the first play item
// PlayItem layout after out_time:
// [20:28] UO_mask_table (8 bytes)
// [28] misc flags (1 byte)
// [29] still_mode (1 byte)
// [30:32] still_time (2 bytes)
// [32:] STN_table
const STN_OFFSET: usize = 32;
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let n_video = item[STN_OFFSET + 4] as usize;
let n_audio = item[STN_OFFSET + 5] as usize;
let n_pg = item[STN_OFFSET + 6] as usize;
let n_ig = item[STN_OFFSET + 7] as usize;
let n_sec_audio = item[STN_OFFSET + 8] as usize;
let n_sec_video = item[STN_OFFSET + 9] as usize;
let n_pip_pg = item[STN_OFFSET + 10] as usize;
let n_dv = item[STN_OFFSET + 11] as usize;
let mut spos = STN_OFFSET + 16;
// Primary video
for _ in 0..n_video {
if let Some((entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO) {
streams.push(entry);
spos = next;
} else {
break;
}
}
// Primary audio
for _ in 0..n_audio {
if let Some((entry, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_AUDIO) {
streams.push(entry);
spos = next;
} else {
break;
}
}
// PG subtitles
for _ in 0..n_pg {
if let Some((entry, next)) =
parse_stream_entry(item, spos, STREAM_CATEGORY_PG_SUBTITLE)
{
streams.push(entry);
spos = next;
} else {
break;
}
}
// IG (skip but advance)
for _ in 0..n_ig {
if let Some((_, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_IG) {
spos = next;
} else {
break;
}
}
// Secondary audio
for _ in 0..n_sec_audio {
if let Some((mut entry, next)) =
parse_stream_entry(item, spos, STREAM_CATEGORY_AUDIO)
{
entry.stream_type = 5;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes: num_refs(1) + reserved(1) + refs + padding
if next < item.len() {
let n_refs = item[next] as usize;
spos = next + 2 + n_refs + (n_refs % 2);
} else {
spos = next;
}
} else {
break;
}
}
// Secondary video (PiP)
for _ in 0..n_sec_video {
if let Some((mut entry, next)) =
parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO)
{
entry.stream_type = 6;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes (audio refs + PG refs).
// Use `next < item.len()` to match the sibling secondary
// blocks; the inner `after_arefs < item.len()` re-guards
// the second read, so the stricter `+2` only mis-aligned
// spos when the aref count sits in the last 1-2 bytes.
if next < item.len() {
let n_arefs = item[next] as usize;
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
if after_arefs < item.len() {
let n_prefs = item[after_arefs] as usize;
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
} else {
spos = after_arefs;
}
} else {
spos = next;
}
} else {
break;
}
}
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
for _ in 0..n_pip_pg {
if let Some((mut entry, next)) =
parse_stream_entry(item, spos, STREAM_CATEGORY_PG_SUBTITLE)
{
entry.secondary = true;
streams.push(entry);
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
if next < item.len() {
let n_refs = item[next] as usize;
spos = next + 2 + n_refs + (n_refs % 2);
} else {
spos = next;
}
} else {
break;
}
}
// Dolby Vision enhancement layer
for _ in 0..n_dv {
if let Some((mut entry, next)) =
parse_stream_entry(item, spos, STREAM_CATEGORY_VIDEO)
{
entry.stream_type = 7;
entry.secondary = true;
streams.push(entry);
spos = next;
} else {
break;
}
}
}
play_items.push(PlayItem {
clip_id,
in_time,
out_time,
connection_condition,
});
pos += 2 + item_length;
}
// Parse PlayListMark section
let mut marks = Vec::new();
// The first real read is num_marks at ms[4..6], so the section needs
// at least 6 bytes (length(4) + num_marks(2)).
if mark_start > 0 && mark_start + 6 <= data.len() {
let ms = &data[mark_start..];
{
let num_marks = u16::from_be_bytes([ms[4], ms[5]]) as usize;
let mut mpos = 6;
for _ in 0..num_marks {
if mpos + 14 > ms.len() {
break;
}
// PlayListMark entry: reserved(1) + mark_type(1) +
// ref_to_PlayItem_id(2) + mark_time_stamp(4) +
// entry_ES_PID(2) + duration(4). mark_type is at +1, not +0.
let mark_type = ms[mpos + 1];
let play_item_ref = u16::from_be_bytes([ms[mpos + 2], ms[mpos + 3]]);
let timestamp =
u32::from_be_bytes([ms[mpos + 4], ms[mpos + 5], ms[mpos + 6], ms[mpos + 7]]);
marks.push(PlaylistMark {
mark_type,
play_item_ref,
timestamp,
});
mpos += 14;
}
}
}
Ok(Playlist {
version,
play_items,
streams,
marks,
})
}
/// Parse one stream entry from the STN table.
/// Returns (StreamEntry, next position) or None.
/// BD `stream_entry()` type codes (the `stream_entry_type` field). Determine
/// where the PID sits within the entry — see `parse_stream_entry`.
const STREAM_ENTRY_PLAYITEM_CLIP: u8 = 0x01; // stream in the PlayItem's Clip
const STREAM_ENTRY_SUBPATH_SUBCLIP: u8 = 0x02; // stream in a SubPath SubClip
const STREAM_ENTRY_SUBPATH_CLIP: u8 = 0x03; // stream in a SubPath clip
const STREAM_ENTRY_SUBPATH_DV_EL: u8 = 0x04; // SubPath Dolby Vision enhancement layer
/// STN-table primary stream categories — the `stream_type` tag carried on each
/// [`StreamEntry`]. Secondary streams reuse the primary category and set the
/// `secondary` flag rather than carrying a distinct code.
const STREAM_CATEGORY_VIDEO: u8 = 1;
const STREAM_CATEGORY_AUDIO: u8 = 2;
const STREAM_CATEGORY_PG_SUBTITLE: u8 = 3;
const STREAM_CATEGORY_IG: u8 = 4;
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
use crate::consts::coding_type as c;
if pos + 2 > item.len() {
return None;
}
// Stream entry: length(1) + data
let se_len = item[pos] as usize;
let se_end = pos + 1 + se_len;
if se_end > item.len() {
return None;
}
// PID location depends on the stream-entry type (BD spec stream_entry()):
// type 1 (stream in the PlayItem's Clip): PID at +2
// type 2 (stream in a SubPath SubClip): +subpath_id(1)+subclip_id(1) → PID at +4
// type 3 / 4 (SubPath clip; type 4 = Dolby Vision +subpath_id(1) → PID at +3
// enhancement layer, e.g. PID 0x1015):
// Previously only type 1 was handled, so the DV EL (type 4) and any
// sub-path stream fell through to PID 0 and were dropped by the mux.
let pid_off = match item[pos + 1] {
STREAM_ENTRY_PLAYITEM_CLIP => 2,
STREAM_ENTRY_SUBPATH_SUBCLIP => 4,
STREAM_ENTRY_SUBPATH_CLIP | STREAM_ENTRY_SUBPATH_DV_EL => 3,
_ => 0,
};
// Bound the PID read by the entry's declared end (se_end), not just by
// item.len(): a short se_len must not let us read PID bytes out of the
// following stream_attributes region.
let pid = if pid_off != 0 && pos + pid_off + 2 <= se_end {
u16::from_be_bytes([item[pos + pid_off], item[pos + pid_off + 1]])
} else {
0
};
// Stream attributes: length(1) + coding_type(1) + format-specific data
if se_end + 2 > item.len() {
return None;
}
let sa_len = item[se_end] as usize;
let sa_end = se_end + 1 + sa_len;
if sa_end > item.len() || sa_len < 1 {
return None;
}
let sa = &item[se_end + 1..se_end + 1 + sa_len];
let coding_type = sa[0];
let mut video_format = 0u8;
let mut video_rate = 0u8;
let mut audio_format = 0u8;
let mut audio_rate = 0u8;
let mut dynamic_range = 0u8;
let mut color_space_val = 0u8;
let mut language = String::new();
// `stream_type` here is the STN category passed by the caller, which is
// only ever a primary category (VIDEO/AUDIO/PG_SUBTITLE/IG). Secondary
// audio/video and the DV enhancement layer are parsed through their
// matching primary category (identical attribute layout) and re-tagged by
// the caller after this returns, so there are no secondary arms here.
match stream_type {
STREAM_CATEGORY_VIDEO => {
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
if sa.len() >= 2 {
video_format = (sa[1] >> 4) & 0x0F;
video_rate = sa[1] & 0x0F;
}
if coding_type == c::HEVC && sa.len() > 2 {
dynamic_range = (sa[2] >> 4) & 0x0F;
color_space_val = sa[2] & 0x0F;
}
}
STREAM_CATEGORY_AUDIO => {
// Audio: coding_type(1) + format_rate(1) + language(3)
// Exception: PG/IG in an audio slot uses PG layout: coding_type(1) + language(3)
if coding_type == c::PG || coding_type == c::IG {
if sa.len() >= 4 {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
} else {
if sa.len() >= 2 {
audio_format = (sa[1] >> 4) & 0x0F;
audio_rate = sa[1] & 0x0F;
}
if sa.len() >= 5 {
language = String::from_utf8_lossy(&sa[2..5]).to_string();
}
}
}
STREAM_CATEGORY_PG_SUBTITLE
// PG: coding_type(1) + language(3).
// IG is parsed only to advance spos and is then discarded by the
// caller, so it deliberately has no arm here.
if sa.len() >= 4 => {
language = String::from_utf8_lossy(&sa[1..4]).to_string();
}
_ => {}
}
Some((
StreamEntry {
stream_type,
pid,
coding_type,
video_format,
video_rate,
audio_format,
audio_rate,
language,
dynamic_range,
color_space: color_space_val,
secondary: false,
},
sa_end,
))
}
#[cfg(test)]
mod tests {
use super::*;
/// A mark entry for test MPLS building.
struct TestMark {
mark_type: u8,
play_item_ref: u16,
timestamp: u32,
}
/// Build a minimal MPLS binary with given play items and STN streams on the first item.
/// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv)
fn build_mpls(
play_items_data: &[(
/*clip_id*/ &[u8; 5],
/*conn*/ u8,
/*in_time*/ u32,
/*out_time*/ u32,
)],
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
stream_entries: &[Vec<u8>], // raw stream entry + attributes bytes for each stream
) -> Vec<u8> {
build_mpls_with_marks(play_items_data, stn_counts, stream_entries, &[])
}
fn build_mpls_with_marks(
play_items_data: &[(
/*clip_id*/ &[u8; 5],
/*conn*/ u8,
/*in_time*/ u32,
/*out_time*/ u32,
)],
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
stream_entries: &[Vec<u8>],
marks: &[TestMark],
) -> Vec<u8> {
let playlist_start: u32 = 40; // right after the 40-byte header
let mut buf = Vec::new();
// File header: "MPLS" + version + playlist_start + mark_start placeholder
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
// mark_start placeholder (will be patched), extension_start, padding to 40 bytes
buf.extend_from_slice(&[0u8; 28]);
// PlayList section starts here (offset 40)
// PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() {
// Build play item content
let mut item = Vec::new();
// [0..5] clip_id
item.extend_from_slice(*clip_id);
// [5..9] codec_id ("M2TS")
item.extend_from_slice(b"M2TS");
// [9] connection_condition in low nibble
item.push(*conn & 0x0F);
// [10..12] reserved
item.extend_from_slice(&[0u8; 2]);
// [12..16] in_time
item.extend_from_slice(&in_time.to_be_bytes());
// [16..20] out_time
item.extend_from_slice(&out_time.to_be_bytes());
// [20..28] UO_mask_table
item.extend_from_slice(&[0u8; 8]);
// [28] misc flags
item.push(0);
// [29] still_mode
item.push(0);
// [30..32] still_time
item.extend_from_slice(&[0u8; 2]);
// STN table (only for the first play item)
if idx == 0 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let stn_header_start = item.len();
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
item.extend_from_slice(&[0u8; 2]); // reserved
item.push(stn_counts.0); // n_video
item.push(stn_counts.1); // n_audio
item.push(stn_counts.2); // n_pg
item.push(stn_counts.3); // n_ig
item.push(stn_counts.4); // n_sec_audio
item.push(stn_counts.5); // n_sec_video
item.push(stn_counts.6); // n_pip_pg
item.push(stn_counts.7); // n_dv
item.extend_from_slice(&[0u8; 4]); // reserved
// Stream entries
for se in stream_entries {
item.extend_from_slice(se);
}
// Patch STN length
let stn_len = (item.len() - stn_header_start - 2) as u16;
let stn_len_bytes = stn_len.to_be_bytes();
item[stn_header_start] = stn_len_bytes[0];
item[stn_header_start + 1] = stn_len_bytes[1];
}
// Write item_length(2) + item
let item_length = item.len() as u16;
buf.extend_from_slice(&item_length.to_be_bytes());
buf.extend_from_slice(&item);
}
// Patch PlayList length
let pl_len = (buf.len() - pl_start - 4) as u32;
let pl_len_bytes = pl_len.to_be_bytes();
buf[pl_start] = pl_len_bytes[0];
buf[pl_start + 1] = pl_len_bytes[1];
buf[pl_start + 2] = pl_len_bytes[2];
buf[pl_start + 3] = pl_len_bytes[3];
// Write PlayListMark section
let mark_start = buf.len() as u32;
// Patch mark_start offset in header (bytes 12-15)
let ms_bytes = mark_start.to_be_bytes();
buf[12] = ms_bytes[0];
buf[13] = ms_bytes[1];
buf[14] = ms_bytes[2];
buf[15] = ms_bytes[3];
// Mark section: length(4) + num_marks(2) + marks(14 each)
let mark_section_len = 2 + marks.len() * 14;
buf.extend_from_slice(&(mark_section_len as u32).to_be_bytes());
buf.extend_from_slice(&(marks.len() as u16).to_be_bytes());
for m in marks {
buf.push(0); // [0] reserved
buf.push(m.mark_type); // [1] mark_type
buf.extend_from_slice(&m.play_item_ref.to_be_bytes()); // [2-3] play_item_ref
buf.extend_from_slice(&m.timestamp.to_be_bytes()); // [4-7] timestamp
buf.extend_from_slice(&[0u8; 6]); // [8-13] entry_ES_PID(2) + duration(4)
}
buf
}
/// Build a stream entry (stream_entry part + stream_attributes part).
/// stream_entry: type=0x01 (PlayItem stream), PID given.
/// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC]
/// For audio: attrs = coding_type(1) + format_rate(1) + language(3)
/// For PG: attrs = coding_type(1) + language(3)
fn build_stream_entry_video(
pid: u16,
coding_type: u8,
format: u8,
rate: u8,
hdr: Option<u8>,
) -> Vec<u8> {
let mut out = Vec::new();
// Stream entry: length(1) + sub_path_type(1) + pid(2)
out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo)
out.push(0x01); // type: PlayItem stream
out.extend_from_slice(&pid.to_be_bytes());
// Stream attributes
let mut attrs = vec![coding_type, (format << 4) | rate];
if let Some(h) = hdr {
attrs.push(h);
}
out.push(attrs.len() as u8); // sa_len
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_audio(
pid: u16,
coding_type: u8,
ch_layout: u8,
sample_rate: u8,
lang: &[u8; 3],
) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(STREAM_ENTRY_PLAYITEM_CLIP);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + format_rate(1) + language(3)
let attrs = vec![
coding_type,
(ch_layout << 4) | sample_rate,
lang[0],
lang[1],
lang[2],
];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(STREAM_ENTRY_PLAYITEM_CLIP);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + language(3)
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
#[test]
fn parse_valid_mpls() {
let in_time: u32 = 90000; // 2 seconds at 45kHz
let out_time: u32 = 4500000; // 100 seconds
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz
let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle
let data = build_mpls(
&[(b"00001", 1, in_time, out_time)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse valid MPLS");
assert_eq!(playlist.version, "0200");
assert_eq!(playlist.play_items.len(), 1);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].in_time, in_time);
assert_eq!(playlist.play_items[0].out_time, out_time);
assert_eq!(playlist.play_items[0].connection_condition, 1);
}
#[test]
fn parse_streams() {
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
let pg = build_stream_entry_pg(0x1200, 0x90, b"fra");
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse");
assert_eq!(playlist.streams.len(), 3);
// Video stream
let v = &playlist.streams[0];
assert_eq!(v.stream_type, 1);
assert_eq!(v.pid, 0x1011);
assert_eq!(v.coding_type, 0x24); // HEVC
assert_eq!(v.video_format, 8); // 2160p
assert_eq!(v.video_rate, 1); // 23.976
assert_eq!(v.dynamic_range, 1); // HDR10
assert_eq!(v.color_space, 2); // BT.2020
assert!(!v.secondary);
// Audio stream
let a = &playlist.streams[1];
assert_eq!(a.stream_type, 2);
assert_eq!(a.pid, 0x1100);
assert_eq!(a.coding_type, 0x83); // TrueHD
assert_eq!(a.audio_format, 6); // 5.1
assert_eq!(a.audio_rate, 1); // 48kHz
assert_eq!(a.language, "eng");
assert!(!a.secondary);
// PG subtitle stream
let s = &playlist.streams[2];
assert_eq!(s.stream_type, 3);
assert_eq!(s.pid, 0x1200);
assert_eq!(s.coding_type, 0x90); // PGS
assert_eq!(s.language, "fra");
assert!(!s.secondary);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
#[test]
fn parse_truncated() {
// Less than 40 bytes
assert!(parse(&[0u8; 10]).is_err());
assert!(parse(b"MPLS0200").is_err());
assert!(parse(&[0u8; 39]).is_err());
}
#[test]
fn parse_multiple_play_items() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[
(b"00001", 1, 90000, 4500000),
(b"00002", 5, 4500000, 9000000),
(b"00003", 6, 9000000, 13500000),
],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let playlist = parse(&data).expect("should parse multiple play items");
assert_eq!(playlist.play_items.len(), 3);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].connection_condition, 1);
assert_eq!(playlist.play_items[1].clip_id, "00002");
assert_eq!(playlist.play_items[1].connection_condition, 5);
assert_eq!(playlist.play_items[1].in_time, 4500000);
assert_eq!(playlist.play_items[2].clip_id, "00003");
assert_eq!(playlist.play_items[2].connection_condition, 6);
assert_eq!(playlist.play_items[2].out_time, 13500000);
}
#[test]
fn parse_secondary_streams() {
// Primary video
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Secondary audio (stream_type 5): build as audio, parser overrides type to 5
let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
// Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min
let mut sec_audio_with_refs = sec_audio_se;
sec_audio_with_refs.push(0); // num_refs = 0
sec_audio_with_refs.push(0); // reserved
// Secondary video (stream_type 6): build as video, parser overrides type to 6
let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
// Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes
let mut sec_video_with_refs = sec_video_se;
sec_video_with_refs.push(0); // n_arefs = 0
sec_video_with_refs.push(0); // reserved
sec_video_with_refs.push(0); // n_prefs = 0
sec_video_with_refs.push(0); // reserved
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video
&[video, sec_audio_with_refs, sec_video_with_refs],
);
let playlist = parse(&data).expect("should parse secondary streams");
// Should have 3 streams: primary video, secondary audio, secondary video
assert_eq!(playlist.streams.len(), 3);
// Primary video
assert_eq!(playlist.streams[0].stream_type, 1);
assert!(!playlist.streams[0].secondary);
// Secondary audio
assert_eq!(playlist.streams[1].stream_type, 5);
assert!(playlist.streams[1].secondary);
assert_eq!(playlist.streams[1].pid, 0x1A00);
// Secondary video
assert_eq!(playlist.streams[2].stream_type, 6);
assert!(playlist.streams[2].secondary);
assert_eq!(playlist.streams[2].pid, 0x1B00);
}
#[test]
fn parse_secondary_video_then_dv_alignment() {
// Regression: the secondary-video ref-skip must use the same
// `next < item.len()` guard as the sibling secondary blocks so spos
// stays aligned for a following stream (here a Dolby Vision EL).
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12));
// Secondary video with audio-ref + PG-ref blocks present.
let mut sec_video_with_refs = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
sec_video_with_refs.push(0); // n_arefs = 0
sec_video_with_refs.push(0); // reserved
sec_video_with_refs.push(0); // n_prefs = 0
sec_video_with_refs.push(0); // reserved
// Dolby Vision enhancement layer immediately after.
let dv_el = build_stream_entry_video(0x1015, 0x24, 8, 1, Some(0x12));
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 1, 0, 1), // 1 video, 1 sec_video, 1 dv
&[video, sec_video_with_refs, dv_el],
);
let playlist = parse(&data).expect("should parse");
assert_eq!(playlist.streams.len(), 3);
// Secondary video
assert_eq!(playlist.streams[1].stream_type, 6);
assert_eq!(playlist.streams[1].pid, 0x1B00);
// DV EL parsed at the correct offset → correct PID and type 7.
assert_eq!(playlist.streams[2].stream_type, 7);
assert_eq!(playlist.streams[2].pid, 0x1015);
assert!(playlist.streams[2].secondary);
}
#[test]
fn parse_marks_chapter_entries() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let marks = vec![
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: 90000,
},
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: 4500000,
},
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: 9000000,
},
];
let data = build_mpls_with_marks(
&[(b"00001", 1, 90000, 13500000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
&marks,
);
let playlist = parse(&data).expect("should parse marks");
assert_eq!(playlist.marks.len(), 3);
assert_eq!(playlist.marks[0].mark_type, 1);
assert_eq!(playlist.marks[0].play_item_ref, 0);
assert_eq!(playlist.marks[0].timestamp, 90000);
assert_eq!(playlist.marks[1].timestamp, 4500000);
assert_eq!(playlist.marks[2].timestamp, 9000000);
}
#[test]
fn parse_marks_chapter_timestamps_correct() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let in_time: u32 = 90000;
// Chapters at 0s, 100s, 200s relative to in_time
let marks = vec![
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: in_time,
},
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: in_time + 45000 * 100,
},
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: in_time + 45000 * 200,
},
TestMark {
mark_type: 2,
play_item_ref: 0,
timestamp: in_time + 45000 * 50,
}, // non-chapter mark
];
let data = build_mpls_with_marks(
&[(b"00001", 1, in_time, in_time + 45000 * 300)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
&marks,
);
let playlist = parse(&data).expect("should parse");
// All 4 marks should be parsed
assert_eq!(playlist.marks.len(), 4);
// Chapter marks (type 1) are 3 of them
let chapter_marks: Vec<_> = playlist.marks.iter().filter(|m| m.mark_type == 1).collect();
assert_eq!(chapter_marks.len(), 3);
// Non-chapter mark (type 2)
assert_eq!(playlist.marks[3].mark_type, 2);
// Verify timestamp conversion: (timestamp - in_time) / 45000
let ch0_secs = (chapter_marks[0].timestamp as f64 - in_time as f64) / 45000.0;
let ch1_secs = (chapter_marks[1].timestamp as f64 - in_time as f64) / 45000.0;
let ch2_secs = (chapter_marks[2].timestamp as f64 - in_time as f64) / 45000.0;
assert!((ch0_secs - 0.0).abs() < 0.001);
assert!((ch1_secs - 100.0).abs() < 0.001);
assert!((ch2_secs - 200.0).abs() < 0.001);
}
#[test]
fn mark_type_read_from_correct_offset() {
// Regression for the mark_type off-by-one: each PlayListMark entry is
// reserved(1) + mark_type(1) + .... The parser must read byte[1], not
// byte[0]. build_mpls_with_marks writes reserved=0 at byte[0] and the
// mark_type at byte[1], so a parser that read byte[0] would see 0 for
// every mark. Use distinct non-zero, non-1 types to make the offset
// error unmistakable.
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let marks = vec![
TestMark {
mark_type: 1, // entry mark (chapter)
play_item_ref: 0,
timestamp: 90000,
},
TestMark {
mark_type: 2, // link point (not a chapter)
play_item_ref: 0,
timestamp: 180000,
},
TestMark {
mark_type: 3, // arbitrary other type
play_item_ref: 0,
timestamp: 270000,
},
];
let data = build_mpls_with_marks(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
&marks,
);
let playlist = parse(&data).expect("should parse marks");
assert_eq!(playlist.marks.len(), 3);
// If the parser read the reserved byte (byte[0] == 0) these would all
// be 0; reading byte[1] yields the real types.
assert_eq!(playlist.marks[0].mark_type, 1);
assert_eq!(playlist.marks[1].mark_type, 2);
assert_eq!(playlist.marks[2].mark_type, 3);
// Only the type-1 mark is a chapter under the corrected convention.
let chapters = playlist.marks.iter().filter(|m| m.mark_type == 1).count();
assert_eq!(chapters, 1);
}
#[test]
fn parse_no_marks_section() {
// When mark_start is 0, no marks should be returned
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[(b"00001", 1, 90000, 4500000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let playlist = parse(&data).expect("should parse without marks");
// build_mpls writes an empty mark section (0 marks)
assert_eq!(playlist.marks.len(), 0);
}
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests below. Grounded in the BD-ROM MPLS spec
// (https://github.com/lw/BluRay/wiki/MPLS) byte layout.
// ─────────────────────────────────────────────────────────────────────
/// Header guard: parse() requires `playlist_start + 10 <= data.len()`
/// before reading the PlayList header (num_play_items at pl[6..8]).
/// A playlist_start that points past EOF must be rejected with
/// MplsParse, not panic.
#[test]
fn playlist_start_past_eof_errs() {
let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
// Overwrite PlayList_start_address (bytes 8..12) with a huge offset.
data[8..12].copy_from_slice(&0xFFFF_0000u32.to_be_bytes());
assert!(parse(&data).is_err());
}
/// Spec: connection_condition is the LOW nibble of PlayItem byte[9]
/// (high nibble is reserved/flags). A byte 0xF5 must yield 5, not 0xF5.
#[test]
fn connection_condition_is_low_nibble_only() {
// Build a custom item where byte[9] = 0xF5 (high nibble set).
// build_mpls masks with &0x0F when writing, so write raw to verify
// the PARSER masks. We patch the item byte directly after building.
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let mut data = build_mpls(
&[(b"00001", 0, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
// Locate PlayItem byte[9]: header(40) + pl_header(10) + item_len(2) + 9.
let conn_idx = 40 + 10 + 2 + 9;
data[conn_idx] = 0xF5;
let pl = parse(&data).expect("should parse");
assert_eq!(pl.play_items[0].connection_condition, 0x05);
}
/// stream_entry() PID location for type 0x02 (stream in a SubPath
/// SubClip): subpath_id(1)+subclip_id(1) precede the PID, so PID is at
/// +4 within the entry. A parser that read +2 (type-1 layout) would
/// pick up the subpath/subclip bytes as the PID.
#[test]
fn stream_entry_type2_pid_at_offset_4() {
// Build a primary-audio entry with stream_entry type 0x02.
// se_len = 5: type(1) + subpath_id(1) + subclip_id(1) + pid(2)
let mut se = vec![
5, // se_len
STREAM_ENTRY_SUBPATH_SUBCLIP, // type: SubPath SubClip
0xAA, // subpath_id (must NOT be read as PID hi)
0xBB, // subclip_id
];
se.extend_from_slice(&0x1100u16.to_be_bytes()); // real PID at +4
// stream_attributes: audio coding(1)+fmt(1)+lang(3)
let attrs = vec![0x83u8, (6 << 4) | 1, b'e', b'n', b'g'];
se.push(attrs.len() as u8);
se.extend_from_slice(&attrs);
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(0, 1, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 1);
assert_eq!(pl.streams[0].pid, 0x1100);
}
/// stream_entry() PID for type 0x03/0x04 (SubPath clip; 0x04 = DV EL):
/// subpath_id(1) precedes PID, so PID is at +3. Cited in source:
/// DV EL PID e.g. 0x1015.
#[test]
fn stream_entry_type4_pid_at_offset_3() {
let mut se = Vec::new();
se.push(4); // se_len: type(1)+subpath_id(1)+pid(2)
se.push(STREAM_ENTRY_SUBPATH_DV_EL); // type 4 (DV EL)
se.push(0x07); // subpath_id (not PID)
se.extend_from_slice(&0x1015u16.to_be_bytes()); // PID at +3
let attrs = vec![0x24u8, (8 << 4) | 1, 0x12]; // HEVC video attrs
se.push(attrs.len() as u8);
se.extend_from_slice(&attrs);
// Put it in the primary-video slot so it's retained as a stream.
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 1);
assert_eq!(pl.streams[0].pid, 0x1015);
}
/// stream_entry() unknown type → pid_off match arm `_ => 0`, so PID is
/// left 0. A type byte of 0x09 (not 1/2/3/4) must yield pid 0, never an
/// out-of-spec read. Grounded in the explicit default arm in source.
#[test]
fn stream_entry_unknown_type_pid_zero() {
let mut se = Vec::new();
se.push(3);
se.push(0x09); // unknown stream_entry type
se.extend_from_slice(&0x1234u16.to_be_bytes());
let attrs = vec![0x24u8, (8 << 4) | 1];
se.push(attrs.len() as u8);
se.extend_from_slice(&attrs);
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 1);
assert_eq!(pl.streams[0].pid, 0); // unknown type → PID not read
}
/// Video stream_attributes: byte[1] high nibble = video_format, low
/// nibble = video_rate (BD spec format/frame_rate packing). Verify the
/// split: 0x84 → format 8 (2160p), rate 4.
#[test]
fn video_attr_nibble_split() {
let video = build_stream_entry_video(0x1011, 0x1B, 8, 4, None);
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams[0].video_format, 8);
assert_eq!(pl.streams[0].video_rate, 4);
}
/// HDR byte (HEVC only, coding_type 0x24): sa[2] high nibble =
/// dynamic_range, low nibble = color_space. For a non-HEVC video
/// (e.g. H264 0x1B) the HDR byte must NOT be consumed even if present,
/// per the `coding_type == 0x24` guard.
#[test]
fn hdr_byte_only_for_hevc() {
// H264 video with a third attr byte present — must stay SDR/unknown.
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, Some(0x12));
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams[0].coding_type, 0x1B);
assert_eq!(pl.streams[0].dynamic_range, 0); // not parsed for H264
assert_eq!(pl.streams[0].color_space, 0);
}
/// Audio language is at sa[2..5] (after coding_type + format_rate),
/// EXCEPT when the audio slot carries a PG coding_type (0x90/0x91),
/// where the layout is coding_type(1)+language(3) → lang at sa[1..4].
/// This branch is explicit in source. Verify the PG-in-audio path.
#[test]
fn pg_coding_in_audio_slot_uses_pg_lang_offset() {
// Audio-slot entry but coding_type 0x90 (PGS): attrs = 0x90 + lang(3).
let mut se = Vec::new();
se.push(3);
se.push(STREAM_ENTRY_PLAYITEM_CLIP);
se.extend_from_slice(&0x1100u16.to_be_bytes());
let attrs = vec![0x90u8, b'j', b'p', b'n']; // PG layout: coding + lang
se.push(attrs.len() as u8);
se.extend_from_slice(&attrs);
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(0, 1, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams[0].coding_type, 0x90);
assert_eq!(pl.streams[0].language, "jpn"); // read from sa[1..4]
// audio_format/rate not parsed in PG branch.
assert_eq!(pl.streams[0].audio_format, 0);
}
/// IG streams (count n_ig, stream_type 4) are consumed to keep the STN
/// cursor aligned but NEVER retained as StreamEntry (doc'd in source).
/// An STN with 1 video + 1 IG + 1 PG must report exactly the video and
/// PG, and the PG must keep its correct PID (proving IG advanced spos).
#[test]
fn ig_consumed_but_not_retained_and_dv_after_aligned() {
// STN parse order is video, audio, PG, IG, sec_audio, sec_video,
// pip_pg, DV. The IG entry must be consumed (advancing spos) but
// never retained. To PROVE IG advanced the cursor, place a Dolby
// Vision EL after the IG: if IG didn't advance spos, the DV parse
// would land on the IG bytes and read the wrong PID.
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12));
let ig = build_stream_entry_pg(0x1400, 0x91, b"eng"); // IG entry bytes
let dv = build_stream_entry_video(0x1015, 0x24, 8, 1, Some(0x12)); // DV EL
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 1, 0, 0, 0, 1), // 1 video, 1 ig, 1 dv
&[video, ig, dv],
);
let pl = parse(&data).expect("should parse");
// 2 retained streams: video + DV EL (IG dropped).
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[0].stream_type, 1);
assert_eq!(pl.streams[0].pid, 0x1011);
// DV EL parsed at correct offset → IG advanced spos past 0x1400.
assert_eq!(pl.streams[1].stream_type, 7);
assert_eq!(pl.streams[1].pid, 0x1015);
assert!(pl.streams.iter().all(|s| s.pid != 0x1400));
}
/// parse_stream_entry short-circuits when the declared stream_entry
/// length runs past the item end (`se_end > item.len()` → None). The
/// STN count loop then `break`s, so a truncated entry yields fewer
/// streams without panicking. Build n_video=2 but only enough bytes
/// for 1 full entry plus a too-long second.
#[test]
fn truncated_stream_entry_stops_without_panic() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Second "entry" declares se_len=200 but supplies no body → None.
let bad = vec![200u8, STREAM_ENTRY_PLAYITEM_CLIP];
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(2, 0, 0, 0, 0, 0, 0, 0), // claims 2 video
&[video, bad],
);
let pl = parse(&data).expect("should not panic on truncated entry");
// Only the first parsed; second aborted the loop.
assert_eq!(pl.streams.len(), 1);
assert_eq!(pl.streams[0].pid, 0x1011);
}
/// PID must be bounded by the entry's declared se_end, not item.len():
/// a short se_len must leave PID 0 rather than reading into the
/// following stream_attributes region (explicit in source comment).
/// se_len=1 (only the type byte) for a type-1 entry → PID read would
/// need bytes at +2/+3 which are inside attrs, so PID must be 0.
#[test]
fn short_se_len_does_not_read_pid_from_attrs() {
// se_len = 1: just the type byte, no PID bytes within the entry.
let mut se = Vec::new();
se.push(1); // se_len = 1
se.push(0x01); // type 1; PID would be at +2 but that's past se_end
// stream_attributes follow immediately.
let attrs = vec![0x1Bu8, (6 << 4) | 1];
se.push(attrs.len() as u8);
se.extend_from_slice(&attrs);
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 1);
// PID bytes lie outside the declared entry → must be 0, not attrs.
assert_eq!(pl.streams[0].pid, 0);
}
/// parse_stream_entry rejects sa_len == 0 (`sa_len < 1` → None). A
/// zero-length stream_attributes block means the entry is unusable and
/// the STN loop must break, not push a degenerate StreamEntry.
#[test]
fn zero_length_stream_attributes_yields_no_stream() {
let mut se = Vec::new();
se.push(3);
se.push(STREAM_ENTRY_PLAYITEM_CLIP);
se.extend_from_slice(&0x1011u16.to_be_bytes());
se.push(0); // sa_len = 0 → parse_stream_entry returns None
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[se],
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.streams.len(), 0);
}
/// PlayListMark timestamp is a big-endian u32 at entry offset +4..+8
/// (after reserved(1)+mark_type(1)+ref(2)). Verify BE decode and that
/// ref_to_PlayItem_id is read from +2..+4.
#[test]
fn mark_timestamp_and_ref_offsets() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let marks = vec![TestMark {
mark_type: 1,
play_item_ref: 0x0203,
timestamp: 0x0A0B0C0D,
}];
let data = build_mpls_with_marks(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
&marks,
);
let pl = parse(&data).expect("should parse");
assert_eq!(pl.marks.len(), 1);
assert_eq!(pl.marks[0].play_item_ref, 0x0203);
assert_eq!(pl.marks[0].timestamp, 0x0A0B0C0D);
}
/// num_marks is read from ms[4..6] (after length(4)). Each entry is
/// strictly 14 bytes. The loop must stop when fewer than 14 bytes
/// remain (`mpos + 14 > ms.len()` → break) rather than panic, so a
/// num_marks that overshoots the actual byte count is safe.
#[test]
fn mark_count_overshoot_truncates_safely() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let marks = vec![
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: 100,
},
TestMark {
mark_type: 1,
play_item_ref: 0,
timestamp: 200,
},
];
let mut data = build_mpls_with_marks(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
&marks,
);
// Find mark_start (header bytes 12..16) and bump num_marks to 99.
let mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
// num_marks at ms[4..6].
data[mark_start + 4] = 0;
data[mark_start + 5] = 99;
let pl = parse(&data).expect("should not panic on mark overshoot");
// Only the 2 real marks fit; the loop broke at the 3rd.
assert_eq!(pl.marks.len(), 2);
}
/// Mark section guard: `mark_start + 6 <= data.len()` is required before
/// reading num_marks at ms[4..6]. A mark_start pointing within 5 bytes
/// of EOF must yield zero marks, not panic.
#[test]
fn mark_start_near_eof_yields_no_marks() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let mut data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
// Point mark_start to len-3 (only 3 bytes remain < 6 needed).
let near = (data.len() - 3) as u32;
data[12..16].copy_from_slice(&near.to_be_bytes());
let pl = parse(&data).expect("should parse");
assert_eq!(pl.marks.len(), 0);
}
/// A PlayItem whose declared item_length leaves fewer than 20 bytes of
/// body is skipped (`item.len() < 20` → continue) — its clip_id/times
/// are not parsed, but the cursor advances and following items still
/// parse. Grounded in the `if item.len() < 20` guard.
#[test]
fn short_play_item_skipped_cursor_advances() {
// Construct two items manually: a short (10-byte) first item, then
// a valid second item. We can't use build_mpls (it always writes
// ≥32-byte items), so assemble directly.
let playlist_start: u32 = 40;
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]); // mark_start=0 + padding
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // pl length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&2u16.to_be_bytes()); // num_play_items = 2
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
// Item 0: length 10 (< 20) → skipped.
let short = vec![0u8; 10];
buf.extend_from_slice(&(short.len() as u16).to_be_bytes());
buf.extend_from_slice(&short);
// Item 1: a valid 32-byte item with clip_id "00009".
let mut item = Vec::new();
item.extend_from_slice(b"00009");
item.extend_from_slice(b"M2TS");
item.push(0x01); // connection_condition
item.extend_from_slice(&[0u8; 2]);
item.extend_from_slice(&90000u32.to_be_bytes()); // in_time
item.extend_from_slice(&180000u32.to_be_bytes()); // out_time
item.resize(32, 0); // pad through STN_OFFSET; item.len()==32 so no STN
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
buf.extend_from_slice(&item);
let pl_len = (buf.len() - pl_start - 4) as u32;
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
let pl = parse(&buf).expect("should parse with a short leading item");
// Only the valid second item is retained.
assert_eq!(pl.play_items.len(), 1);
assert_eq!(pl.play_items[0].clip_id, "00009");
assert_eq!(pl.play_items[0].in_time, 90000);
}
/// data.len() exactly 40 with valid magic but playlist_start past the
/// header: parse() must hit the `playlist_start + 10 > data.len()`
/// guard. A 40-byte buffer with playlist_start=40 has no PlayList body.
#[test]
fn exactly_40_bytes_no_playlist_body_errs() {
let mut data = vec![0u8; 40];
data[0..4].copy_from_slice(b"MPLS");
data[4..8].copy_from_slice(b"0200");
data[8..12].copy_from_slice(&40u32.to_be_bytes()); // playlist_start = 40 = len
assert!(parse(&data).is_err());
}
// ─────────────────────────────────────────────────────────────────────
// Added: STN-table block alignment and section-boundary hardening.
// ─────────────────────────────────────────────────────────────────────
/// Build an MPLS from raw PlayItem bodies, with no PlayListMark section
/// (mark_start = 0). Lets a test control item_length exactly.
fn build_mpls_raw_items(items: &[Vec<u8>]) -> Vec<u8> {
let playlist_start: u32 = 40;
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]); // mark_start = 0, then padding
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // PlayList length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(items.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for it in items {
buf.extend_from_slice(&(it.len() as u16).to_be_bytes());
buf.extend_from_slice(it);
}
let pl_len = (buf.len() - pl_start - 4) as u32;
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
buf
}
/// The 20 bytes a PlayItem needs for clip_id(5) + codec_id(4) +
/// connection_condition(1) + reserved(2) + IN_time(4) + OUT_time(4).
fn play_item_20(clip: &[u8; 5], cc: u8, in_t: u32, out_t: u32) -> Vec<u8> {
let mut it = Vec::new();
it.extend_from_slice(clip);
it.extend_from_slice(b"M2TS");
it.push(cc);
it.extend_from_slice(&[0u8; 2]);
it.extend_from_slice(&in_t.to_be_bytes());
it.extend_from_slice(&out_t.to_be_bytes());
assert_eq!(it.len(), 20);
it
}
/// A PlayItem body of exactly 20 bytes carries every field the parser
/// reads (the last is OUT_time at [16..20]), so it must be RECORDED,
/// not skipped — and it has no STN table, which starts at byte 32.
#[test]
fn play_item_of_exactly_20_bytes_is_recorded_without_stn() {
let data = build_mpls_raw_items(&[play_item_20(b"00007", 5, 90_000, 180_000)]);
let pl = parse(&data).expect("a 20-byte PlayItem must parse");
assert_eq!(pl.play_items.len(), 1);
assert_eq!(pl.play_items[0].clip_id, "00007");
assert_eq!(pl.play_items[0].in_time, 90_000);
assert_eq!(pl.play_items[0].out_time, 180_000);
assert_eq!(pl.play_items[0].connection_condition, 5);
assert!(pl.streams.is_empty(), "no STN table exists below byte 32");
}
/// A 40-byte MPLS whose PlayList section is exactly its 10-byte header
/// (length(4)+reserved(2)+num_play_items(2)+num_sub_paths(2)) ending at
/// EOF is structurally complete, not truncated: nothing the parser reads
/// lies past the buffer, so it must parse to an empty playlist.
#[test]
fn minimum_size_mpls_with_empty_playlist_header_parses() {
let mut data = vec![0u8; 40];
data[0..4].copy_from_slice(b"MPLS");
data[4..8].copy_from_slice(b"0200");
data[8..12].copy_from_slice(&30u32.to_be_bytes()); // playlist_start + 10 == 40
// mark_start (12..16) stays 0; num_play_items at data[36..38] is 0.
let pl = parse(&data).expect("40-byte MPLS with a complete PlayList header must parse");
assert!(pl.play_items.is_empty());
assert!(pl.streams.is_empty());
assert!(pl.marks.is_empty());
}
/// A mark_start of 0 means "no PlayListMark section". The file header
/// bytes at offset 0 must not be decoded as one — data[4..6] is the
/// version string "02", which as a big-endian num_marks would be 12338.
#[test]
fn mark_start_zero_does_not_parse_header_as_marks() {
let data = build_mpls_raw_items(&[play_item_20(b"00007", 1, 0, 90_000)]);
assert_eq!(
&data[12..16],
&[0, 0, 0, 0],
"fixture must have mark_start 0"
);
let pl = parse(&data).expect("should parse");
assert!(
pl.marks.is_empty(),
"mark_start == 0 must mean absent, got {} marks",
pl.marks.len()
);
}
/// Full STN table walk with every category populated and DISTINCT
/// counts, so no count byte can be read from a neighbour's offset
/// without changing the result.
///
/// Each secondary block is followed by its reference block(s), which
/// per the BD STN table are num_refs(1) + reserved(1) + one byte per
/// ref + one padding byte when the ref count is odd. Every ref count
/// here is 1 — the value that distinguishes `n % 2` (=1) from `n / 2`
/// (=0) — so a wrong skip length misaligns the cursor and every
/// following stream decodes from the wrong offset. IG entries are
/// consumed to keep the cursor aligned but never retained.
#[test]
fn full_stn_table_block_alignment() {
let mut entries: Vec<Vec<u8>> = vec![
build_stream_entry_video(0x1011, 0x1B, 6, 1, None),
build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"),
build_stream_entry_audio(0x1101, 0x86, 3, 1, b"fra"),
build_stream_entry_pg(0x1200, 0x90, b"eng"),
build_stream_entry_pg(0x1201, 0x90, b"fra"),
build_stream_entry_pg(0x1202, 0x90, b"deu"),
];
for i in 0..4u16 {
entries.push(build_stream_entry_pg(0x1400 + i, 0x91, b"eng"));
}
// secondary audio + its secondary-audio ref block (1 ref → 1 pad)
let mut sec_audio = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"spa");
sec_audio.extend_from_slice(&[1, 0, 0x55, 0x00]);
entries.push(sec_audio);
// secondary video + audio-ref block + PiP-PG-ref block
let mut sec_video = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
sec_video.extend_from_slice(&[1, 0, 0x55, 0x00]);
sec_video.extend_from_slice(&[1, 0, 0x66, 0x00]);
entries.push(sec_video);
// PiP PG + its ref block
let mut pip_pg = build_stream_entry_pg(0x1C00, 0x90, b"jpn");
pip_pg.extend_from_slice(&[1, 0, 0x77, 0x00]);
entries.push(pip_pg);
// Dolby Vision enhancement layer
entries.push(build_stream_entry_video(0x1015, 0x24, 8, 1, Some(0x12)));
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 2, 3, 4, 1, 1, 1, 1),
&entries,
);
let pl = parse(&data).expect("should parse");
let got: Vec<(u8, u16, bool)> = pl
.streams
.iter()
.map(|s| (s.stream_type, s.pid, s.secondary))
.collect();
assert_eq!(
got,
vec![
(1, 0x1011, false), // primary video
(2, 0x1100, false), // primary audio ×2
(2, 0x1101, false),
(3, 0x1200, false), // PG ×3
(3, 0x1201, false),
(3, 0x1202, false),
// the 4 IG entries are consumed and discarded
(5, 0x1A00, true), // secondary audio
(6, 0x1B00, true), // secondary video
(3, 0x1C00, true), // PiP PG
(7, 0x1015, true), // Dolby Vision EL
]
);
// Languages prove each entry was decoded at its own offset.
assert_eq!(pl.streams[1].language, "eng");
assert_eq!(pl.streams[2].language, "fra");
assert_eq!(pl.streams[6].language, "spa");
assert_eq!(pl.streams[8].language, "jpn");
}
/// A secondary block whose stream entry ends exactly at the end of the
/// PlayItem has no reference block at all; the count byte must not be
/// read from one-past-the-end. Covers all three secondary blocks that
/// carry reference data.
#[test]
fn secondary_ref_block_at_item_end_is_not_read() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Secondary audio is the last entry, with no ref bytes following.
let sec_audio = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 1, 0, 0, 0),
&[video.clone(), sec_audio],
);
let pl = parse(&data).expect("secondary audio at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1A00);
// Secondary video is the last entry, with no ref bytes following.
let sec_video = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 1, 0, 0),
&[video.clone(), sec_video.clone()],
);
let pl = parse(&data).expect("secondary video at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1B00);
// Secondary video whose audio-ref block ends exactly at item end, so
// the PiP-PG ref count byte would sit one past it.
let mut sec_video_arefs = sec_video;
sec_video_arefs.extend_from_slice(&[0, 0]); // n_arefs = 0, reserved
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 1, 0, 0),
&[video.clone(), sec_video_arefs],
);
let pl = parse(&data).expect("secondary video aref block at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1B00);
// PiP PG is the last entry, with no ref bytes following.
let pip_pg = build_stream_entry_pg(0x1C00, 0x90, b"jpn");
let data = build_mpls(
&[(b"00001", 1, 0, 9_000_000)],
(1, 0, 0, 0, 0, 0, 1, 0),
&[video, pip_pg],
);
let pl = parse(&data).expect("PiP PG at item end");
assert_eq!(pl.streams.len(), 2);
assert_eq!(pl.streams[1].pid, 0x1C00);
}
// ─────────────────────────────────────────────────────────────────────
// parse_stream_entry bounds, exercised directly.
// ─────────────────────────────────────────────────────────────────────
/// Fewer than 2 bytes remain for the stream_entry header
/// (length(1) + stream_entry_type(1)) → None, without reading either.
#[test]
fn stream_entry_header_past_end_is_none() {
let item = [0u8; 8];
for pos in 7..12usize {
assert!(
parse_stream_entry(&item, pos, STREAM_CATEGORY_VIDEO).is_none(),
"pos={pos}"
);
}
}
/// The stream_attributes header (length(1) + coding_type(1)) lies past
/// the end of the PlayItem → None, without reading the length byte.
#[test]
fn stream_attributes_header_past_end_is_none() {
// se_len = 3 → se_end = 4 == item.len(); the sa length byte would be
// at item[4] and the coding type at item[5].
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11];
assert!(parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).is_none());
}
/// A declared stream_attributes length of 0 has no coding_type byte and
/// must be rejected — even when the (empty) attribute region is itself
/// in bounds.
#[test]
fn zero_length_attributes_in_bounds_is_none() {
// se_len = 3 → se_end = 4; sa_len = item[4] = 0 → sa_end = 5 ≤ 6.
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11, 0, 0];
assert!(parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).is_none());
}
/// stream_attributes of exactly 1 byte carries only the coding_type.
/// That is the minimum the parser accepts, so the entry is returned
/// with its PID and coding_type and no format-specific fields — for a
/// PG stream the 3-byte language must NOT be read past the attributes.
#[test]
fn one_byte_stream_attributes_yields_bare_entry() {
// se_len = 3 → se_end = 4; sa_len = 1 → sa_end = 6 == item.len().
let item = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x10, 0x11, 1, 0x1B];
let (entry, next) =
parse_stream_entry(&item, 0, STREAM_CATEGORY_VIDEO).expect("1-byte attrs are valid");
assert_eq!(entry.pid, 0x1011);
assert_eq!(entry.coding_type, 0x1B);
assert_eq!(entry.video_format, 0);
assert_eq!(entry.video_rate, 0);
assert_eq!(next, 6);
let pg = [3u8, STREAM_ENTRY_PLAYITEM_CLIP, 0x12, 0x00, 1, 0x90];
let (entry, _) = parse_stream_entry(&pg, 0, STREAM_CATEGORY_PG_SUBTITLE)
.expect("1-byte PG attrs are valid");
assert_eq!(entry.pid, 0x1200);
assert_eq!(entry.coding_type, 0x90);
assert_eq!(entry.language, "");
}
/// Type 0 is reserved and type 2 is a link point; neither is a chapter.
/// `labels::collect_chapter_summary` used to filter on `mark_type <= 1`,
/// which counted reserved marks and inflated the public `chapter_count`
/// (and let a playlist whose only marks are reserved pass the
/// `chapter_count == 0` skip). Both call sites now share this predicate.
#[test]
fn only_entry_marks_count_as_chapters() {
let mk = |mark_type| PlaylistMark {
mark_type,
play_item_ref: 0,
timestamp: 0,
};
assert!(
!mk(0).is_chapter_mark(),
"type 0 is reserved, not a chapter"
);
assert!(mk(1).is_chapter_mark());
assert!(!mk(2).is_chapter_mark(), "type 2 is a link point");
}
}