Files
libfreemkv/src/disc/hddvd.rs
T
Matthew Jackson 7abcfaab72 Stop the HD-DVD VTI read and the extract progress channel from hiding loss
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.
2026-08-19 09:10:26 -07:00

3640 lines
155 KiB
Rust

//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration.
//!
//! HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like
//! FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an
//! MPEG **program** stream — each with a small `.map` timemap sidecar, navigated
//! by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a
//! different tree with a different playlist format, it gets its OWN scanner
//! (this file), a peer to [`Disc::scan_bluray_titles`] — the two-format design
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
//! into ONE title with the two parts as clips, carrying each clip's title-time
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
//! handles it. Per-clip streams are enumerated by demuxing the clip head and
//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
//! codec sniffed from the ES bytes.
//!
//! When no playlist is present (or it fails to parse), the scanner falls back to
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
//! `feature*`-named clips into one title, and emit every other clip on its own.
//!
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
//! audio languages (the XPL carries `<Audio description=...>` but they are not yet
//! wired onto the streams). Extents and size are real (the ripper images the
//! clips).
use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
use crate::sector::SectorSource;
use crate::udf;
use std::collections::BTreeMap;
/// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a
/// separate tree from BD, so this is a separate constant — deliberately NOT an
/// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`.
const HDDVD_CLIP_EXT: &str = ".evo";
/// Sectors of an `.evo` clip head to demux when probing its elementary streams
/// (~16 MiB). Enough to see the opening video access unit (SPS) plus every
/// interleaved audio sub-stream, without imaging the whole multi-GiB clip.
const EVO_PROBE_SECTORS: u32 = 8192;
/// Cap on the elementary-stream sample retained per stream while probing — a
/// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB
/// is generous while bounding probe memory.
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
/// `.evo` in authored order.
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
/// Byte stride between clip-table entries in the VTI. Each entry holds a
/// NUL-terminated `<name>.EVO` at a constant sub-offset, so every clip name
/// shares one residue modulo this stride — the signal used to isolate the table.
const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
/// Cap on clip-name hits collected from a VTI. A real clip table holds a few
/// dozen entries; this bounds the scan so a crafted VTI packed with millions of
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
const MAX_VTI_HITS: usize = 8192;
/// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip
/// filenames in authored (table) order.
///
/// The table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a
/// NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer,
/// this collects every NUL-terminated `*.EVO` name and keeps the largest group
/// sharing one residue modulo the stride — the clip table — in offset order.
/// Returns empty for a non-VTI blob or one with no recognizable table.
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
if !vti.starts_with(HDDVD_VTI_MAGIC) {
return Vec::new();
}
let is_name_byte = |b: u8| b.is_ascii_graphic();
// Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares
// one residue, so the largest bucket is it (avoids an O(stride*hits) rescan).
let mut buckets: std::collections::HashMap<usize, Vec<(usize, String)>> =
std::collections::HashMap::new();
let mut count = 0usize;
let mut i = 0usize;
while i < vti.len() && count < MAX_VTI_HITS {
if !is_name_byte(vti[i]) {
i += 1;
continue;
}
let start = i;
while i < vti.len() && is_name_byte(vti[i]) {
i += 1;
}
let name = &vti[start..i];
let nul_terminated = i < vti.len() && vti[i] == 0;
if nul_terminated && name.len() >= 5 && name[name.len() - 4..].eq_ignore_ascii_case(b".EVO")
{
buckets
.entry(start % VTI_CLIP_ENTRY_STRIDE)
.or_default()
.push((start, String::from_utf8_lossy(name).into_owned()));
count += 1;
}
}
// Pick the largest residue bucket (the clip table). On a size tie, break
// deterministically by the bucket's smallest offset — `HashMap` iteration
// order is randomized, so `max_by_key` alone could pick a different bucket
// run-to-run on identical bytes.
let Some(mut best) = buckets
.into_values()
.max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min())))
else {
return Vec::new();
};
best.sort_by_key(|(o, _)| *o);
best.into_iter().map(|(_, n)| n).collect()
}
/// Whether a clip belongs to the main feature. HD-DVD Standard Content authors
/// the feature as one or more clips whose name begins `feature` (case-insensitive)
/// — `FEATURE_1`/`FEATURE_2` (a layer-break split) or `feature`/`feature_Divide`.
/// The feature is imaged as ONE title by concatenating these in authored order.
fn is_feature_clip(name: &str) -> bool {
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
base.to_ascii_lowercase().starts_with("feature")
}
/// Sniff a video codec from a program-stream video elementary-stream sample by
/// its MPEG / Annex-B start codes:
/// * `00 00 01 B3` → MPEG-2 (sequence_header)
/// * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU)
/// * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264
///
/// Returns `None` when no recognizable start code is present. The scan prefers
/// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
/// NAL so a stray slice/picture code can't be mistaken for a different codec.
fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
let mut saw_h264_sps = false;
let mut i = 0usize;
while i + 4 <= es.len() {
if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 {
let code = es[i + 3];
match code {
0xB3 => return Some(Codec::Mpeg2),
0x0F => return Some(Codec::Vc1),
// H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the
// forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits).
// 0x07/0x27/0x47/0x67 all decode to a type-7 SPS.
_ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
_ => {}
}
// Skip the whole consumed `00 00 01 <code>` marker (4 bytes) so the
// code byte isn't re-read as the start of an overlapping start code.
i += 4;
} else {
i += 1;
}
}
saw_h264_sps.then_some(Codec::H264)
}
/// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only
/// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is
/// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an
/// unrecognized sample so the caller drops the stream rather than mislabeling it.
fn sniff_audio_codec(es: &[u8]) -> Option<Codec> {
let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77);
has_sync.then_some(Codec::Ac3Plus)
}
/// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and
/// build one [`Stream`] per distinct elementary stream found: the video track
/// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream
/// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes.
///
/// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
/// language / channels use sane HD-DVD defaults (the muxer reads the true pixel
/// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
/// here). Returns an empty vec when the clip cannot be read or carries no
/// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
///
/// Cancellation is checked before every chunk read and is the ONE condition
/// that returns `Err` rather than an empty vec: a probe cut short by a Stop has
/// not established that the clip carries no streams, and reporting it as if it
/// had would enumerate a stream-less title as a scanned fact. A read that fails
/// for any other reason keeps the existing best-effort behaviour.
fn probe_evo_streams(
reader: &mut dyn SectorSource,
extents: &[Extent],
halt: Option<&crate::halt::Halt>,
) -> Result<Vec<Stream>> {
let mut demux = PsDemuxer::new();
let mut video: Vec<u8> = Vec::new();
// Routing PID of the video track, captured from the first video PES seen:
// `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2),
// or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video
// (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid`
// so the emitted `Stream` PID matches what the demuxer routes at mux time.
let mut video_pid: Option<u16> = None;
// sub_id -> ES sample, ordered so audio tracks surface in sub-id order.
let mut audio: BTreeMap<u8, Vec<u8>> = BTreeMap::new();
let mut remaining = EVO_PROBE_SECTORS;
'outer: for ext in extents {
let mut lba = ext.start_lba;
let mut left = ext.sector_count;
while left > 0 && remaining > 0 {
// 1 MiB read chunks (512 sectors) keep buffers small.
// Poll before every chunk, as the PGS probe does: a clip's probe
// budget is 16 MiB of blocking reads, and on marginal media each
// one can sit in the drive's retry path, so per-clip granularity
// alone would leave a Stop waiting on a dead disc.
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted);
}
let n = left.min(remaining).min(512) as u16;
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
match reader.read_sectors(lba, n, &mut buf, false) {
Ok(_) => {}
// A live-drive Stop surfaces HERE, not through the token:
// `Drive::checked_exec` fails every command with `Halted` once
// the flag is set. Swallowing it (as any other read error is
// swallowed) reports an un-probed clip as a probed one.
Err(crate::error::Error::Halted) => {
return Err(crate::error::Error::Halted);
}
Err(_) => break 'outer,
}
for pkt in demux.feed(&buf) {
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
}
lba += n as u32;
left -= n as u32;
remaining -= n as u32;
}
}
for pkt in demux.flush() {
collect_es(&pkt, &mut video, &mut video_pid, &mut audio);
}
let mut streams = Vec::new();
// Emit the video stream only when the codec was actually identified from the
// sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a
// still-encrypted — clip with the wrong codec, so the mux applies the wrong
// parser and produces a corrupt track; dropping it is the honest outcome
// (matches the audio path below), and a real clear clip always carries its
// sequence header / SPS at the head, so this never fires on a normal disc.
if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) {
streams.push(Stream::Video(VideoStream {
pid,
codec,
// HD-DVD is HD (1080). The muxer reads the true coded dimensions
// from the H.264/VC-1 bitstream; this is a coarse default only.
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}));
}
for (sub, sample) in &audio {
let Some(codec) = sniff_audio_codec(sample) else {
continue;
};
let Some(pid) = dvd_audio_pid(*sub) else {
continue;
};
streams.push(Stream::Audio(AudioStream {
pid,
codec,
// DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at
// scan time, so this is a default (a 2.0 track is over-stated as
// 5.1 in the header — the compressed audio itself is unaffected).
channels: AudioChannels::Surround51,
language: String::new(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
}));
}
Ok(streams)
}
/// Accumulate a demuxed PES packet's elementary-stream bytes into the video /
/// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]).
fn collect_es(
pkt: &crate::mux::ps::PsPacket,
video: &mut Vec<u8>,
video_pid: &mut Option<u16>,
audio: &mut BTreeMap<u8, Vec<u8>>,
) {
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
const EXTENDED_STREAM_ID: u8 = 0xFD;
/// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension`
/// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions —
/// routing those to their own audio tracks is deferred (see the HD-DVD
/// program-chain follow-up); until then only the VC-1 extension is treated as
/// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID.
const VC1_STREAM_ID_EXT: u8 = 0x55;
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
let is_vc1_ext =
pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT);
match pkt.stream_id {
// Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD
// extended-stream-id (0xFD). Both feed the single video ES sample; the
// routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer.
VIDEO..=VIDEO_MAX => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
if video.len() < EVO_ES_SAMPLE_CAP {
video.extend_from_slice(&pkt.data);
}
}
EXTENDED_STREAM_ID if is_vc1_ext => {
if video_pid.is_none() {
*video_pid = pkt.dvd_pid();
}
if video.len() < EVO_ES_SAMPLE_CAP {
video.extend_from_slice(&pkt.data);
}
}
PRIVATE_STREAM_1 => {
if let Some(sub) = pkt.sub_stream_id
&& (0xC0..=0xC7).contains(&sub)
{
let slot = audio.entry(sub).or_default();
if slot.len() < EVO_ES_SAMPLE_CAP {
slot.extend_from_slice(&pkt.data);
}
}
}
_ => {}
}
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
//
// HD-DVD Advanced Content ships an authoritative playlist at
// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
// player playlist: a `<TitleSet>` of `<Title>`s, each naming its
// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback
// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
// `<ChapterList>`. Parsing it gives authoritative title composition — clips,
// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
// the per-clip title-time offsets needed to splice a layer-break split
// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.
/// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower
/// case) and the clip's placement on the title timeline, in seconds.
struct XplClip {
evo: String,
begin_secs: f64,
end_secs: f64,
}
/// One `<Title>` from the XPL: number, display name, total duration, its clips
/// in playback order, and chapter start times (seconds).
struct XplTitle {
number: u16,
name: String,
duration_secs: f64,
clips: Vec<XplClip>,
chapters: Vec<f64>,
}
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
/// seconds. `None` on a malformed field.
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
let n: Vec<u32> = s
.split(':')
.map(|p| p.trim().parse::<u32>())
.collect::<std::result::Result<Vec<u32>, _>>()
.ok()?;
let tb = tick_base.max(1) as f64;
let (h, m, sec, f) = match n.as_slice() {
[h, m, s, f] => (*h, *m, *s, *f),
[m, s, f] => (0, *m, *s, *f),
_ => return None,
};
Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
}
/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
fn parse_tick_base(s: &str) -> u32 {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().unwrap_or(60)
}
/// `<PrimaryAudioVideoClip src="file:///.../FEATURE_1.MAP">` → `feature_1.evo`:
/// take the basename, drop the extension, normalise to a lower-case `.evo` name
/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
fn evo_from_src(src: &str) -> Option<String> {
let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
if stem.is_empty() {
return None;
}
Some(format!("{}.evo", stem.to_ascii_lowercase()))
}
/// Maximum element nesting depth accepted in an XPL. A real `VPLST000.XPL` is
/// about six levels deep at its deepest
/// (`Playlist`/`TitleSet`/`Title`/`PrimaryAudioVideoClip`/`Video`, or
/// `.../ChapterList/Chapter`); 32 leaves a 5x margin for authoring tools that
/// wrap extra `ApplicationSegment`/`ObjectMappingList` layers, while staying
/// trivially stack-safe.
const MAX_XPL_DEPTH: usize = 32;
/// Most `<Title>` elements one playlist may declare.
///
/// The depth guard above bounds NESTING; this bounds BREADTH, and they are
/// separate attacks. A flat playlist passes the depth check trivially and can
/// still declare a title per 51 bytes, so a 64 MiB `VPLST000.XPL` — exactly
/// `udf::MAX_FILE_BYTES` — reaches ~1.3 million of them.
///
/// That matters here and not on the Blu-ray side because of where the count
/// comes from: `bluray.rs` learns its playlists from the MPLS files actually
/// present in the UDF directory, so the medium bounds it. An XPL DECLARES its
/// titles, so nothing does.
///
/// The cost is not memory but drive time. `compose_xpl_titles` runs
/// `probe_evo_streams` once per title, and each probe reads real sectors — so
/// an uncapped count turns a `Disc::scan()` the operator expects to take
/// seconds into tens of terabytes of optical reads on a disc that is only
/// pretending to be large.
///
/// The bound that matters is `cap x EVO_PROBE_SECTORS`, since each title costs
/// one probe: `compose_xpl_titles` memoizes on the title's RESOLVED extent list,
/// and distinct titles built from different combinations of the same clips have
/// different lists, so each one misses the memo. At 4096 that product was 64 GiB
/// of optical reads — the same worst case, and the same contradiction with
/// "seconds", as the sibling [`MAX_HDDVD_CLIPS`] path.
///
/// 512 keeps it at 8 GiB and remains far above any real disc (retail HD-DVDs
/// carry tens of titles).
const MAX_XPL_TITLES: usize = 512;
/// Cap on `.evo` clips taken from the `HVDVD_TS/` directory listing.
///
/// [`MAX_XPL_TITLES`] bounds what a PLAYLIST may declare, and
/// [`xpl_depth_within_limit`] bounds how deeply it may nest — but both live on
/// the `ADV_OBJ/*.XPL` path, and a crafted disc simply OMITS `/ADV_OBJ`. Control
/// then reaches the clip-name fallback in [`Disc::scan_hddvd_titles`], which
/// emits one title per DIRECTORY ENTRY and probes each. That is the same
/// unbounded-probe cost, reached without the playlist.
///
/// The directory is the only bound the medium supplies, and `udf::MAX_DIR_BYTES`
/// (1 MiB) still leaves room for ~24,000 FIDs. Memoizing the probe
/// ([`EvoProbeCache`]) collapses entries that resolve to the SAME extents, which
/// closes the many-names-one-File-Entry case — but it cannot close this one: a
/// File Entry is a single sector, so an attacker can give every FID its own,
/// each declaring its own extent, for ~48 MiB of image. Every probe then misses
/// the memo and costs a full [`EVO_PROBE_SECTORS`] (16 MiB) read — hundreds of
/// GiB on a `Disc::scan()` the operator expects to take seconds.
///
/// The cap's job is therefore to bound `cap x EVO_PROBE_SECTORS`, and only that
/// product means anything. It was 4096 — chosen to match [`MAX_XPL_TITLES`],
/// which bounds a different path with no per-item read cost — and 4096 x 16 MiB
/// is 64 GiB of optical reads: more than dual-layer HD-DVD media physically
/// holds (reachable anyway, since distinct extent lists may overlap on the
/// medium) and tens of minutes to hours at drive speeds. That is not "a scan
/// the operator expects to take seconds"; the cap stopped literal
/// unboundedness while permitting a worst case the same doc ruled out.
///
/// 512 keeps the product at 8 GiB and still leaves a wide margin over any real
/// disc — retail HD-DVDs carry TENS of `.evo` clips, so this is ~10x the
/// real-world maximum, the same sizing convention
/// [`MAX_XPL_CHAPTERS_PER_TITLE`] uses against the 99-chapter authoring
/// ceiling. No genuine disc loses a title.
///
/// Pinned by `the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume`,
/// which asserts the PRODUCT rather than either constant, so raising one
/// without re-examining the other fails.
const MAX_HDDVD_CLIPS: usize = 512;
/// Most `<PrimaryAudioVideoClip>` elements one `<Title>` may contribute.
///
/// The fourth amplification axis on this function, and the one the other three
/// guards leave open. [`MAX_XPL_DEPTH`] bounds NESTING, [`MAX_XPL_TITLES`]
/// bounds how many titles a playlist DECLARES, and [`MAX_HDDVD_CLIPS`] bounds
/// the directory fallback — none of them bounds how many clips a SINGLE title
/// collects.
///
/// Two things multiply here. A 64 MiB XPL (exactly `udf::MAX_FILE_BYTES`) holds
/// ~1.7 million `<PrimaryAudioVideoClip src="A.EVO"/>` elements at ~38 bytes
/// each. And the collector below is `descendants()`, not `children()`, so a clip
/// nested inside N ancestor `<Title>` elements is collected by EVERY one of
/// them — and the depth cap still permits 32 such ancestors (measured: a bare
/// `<Title>` root nests 32 deep and still parses, because the guard counts the
/// self-closing clip element as non-nesting). The product is ~1.7M x 32 ≈ 56
/// million heap-allocated [`XplClip`]s, each with its own `String`, and then as
/// many [`Clip`]s again in [`compose_xpl_titles`].
///
/// Unlike the title cap, the cost here is MEMORY rather than drive time:
/// `compose_xpl_titles` de-duplicates by `.evo` name before probing
/// (`seen_evos`), so repeats cost no extra sector reads — but the `Vec`s are
/// built and held regardless.
///
/// 256 is far above any real disc. A retail HD-DVD title is a handful of clips —
/// a feature is one, or two across a layer break; even a seamless-branching or
/// multi-angle title is tens. It also keeps the aggregate bounded: at most
/// [`MAX_XPL_TITLES`] x 256 clips, rather than the tens of millions above.
const MAX_XPL_CLIPS_PER_TITLE: usize = 256;
/// Most `<Chapter>` elements one `<Title>` may contribute.
///
/// The same `descendants()` amplification as [`MAX_XPL_CLIPS_PER_TITLE`], on the
/// second unbounded `collect()` in the same loop, and bounded separately so that
/// capping clips does not simply move the attack next door. A chapter is a bare
/// `f64` rather than a `String`-carrying struct, so each one is cheaper — but a
/// `<Chapter titleTimeBegin="…"/>` element is also smaller, so the element count
/// an XPL can reach is comparable.
///
/// 1024 is far above any real disc: the DVD/HD-DVD authoring convention tops out
/// at 99 chapters per title, so this leaves a 10x margin.
const MAX_XPL_CHAPTERS_PER_TITLE: usize = 1024;
/// Memoized [`probe_evo_streams`], keyed on the RESOLVED extent list.
///
/// Probing is the expensive half of title composition: each pass reads up to
/// [`EVO_PROBE_SECTORS`] (16 MiB) off the medium. Both composition paths can
/// reach the same physical clip many times over — an XPL naming one `.evo` from
/// many `<Title>`s, or a directory whose FIDs all point at one File Entry — and
/// a probe is a pure function of the extents it reads, so the second and later
/// passes over an identical extent list are re-reads of bytes already seen.
///
/// The key is the extent list itself, NOT the clip name or title: two names for
/// one File Entry share a key (one probe), while two genuinely different clips
/// have different `start_lba`s and so keep their own probes and their own
/// streams. Cache size is bounded by the caller's title/clip cap
/// ([`MAX_XPL_TITLES`] / [`MAX_HDDVD_CLIPS`]).
#[derive(Default)]
struct EvoProbeCache {
seen: std::collections::HashMap<Vec<(u32, u32)>, Vec<Stream>>,
}
impl EvoProbeCache {
/// Streams for `extents` — probed on first sight, replayed from the memo
/// afterwards.
fn streams(
&mut self,
reader: &mut dyn SectorSource,
extents: &[Extent],
halt: Option<&crate::halt::Halt>,
) -> Result<Vec<Stream>> {
let key: Vec<(u32, u32)> = extents
.iter()
.map(|e| (e.start_lba, e.sector_count))
.collect();
if let Some(hit) = self.seen.get(&key) {
return Ok(hit.clone());
}
// A cancelled probe is not memoised: it never established what the
// clip holds, so replaying it for the next title that shares these
// extents would spread one Stop into a disc-wide "no streams" verdict.
let streams = probe_evo_streams(reader, extents, halt)?;
self.seen.insert(key, streams.clone());
Ok(streams)
}
}
/// Reject a disc-supplied XML blob whose element nesting exceeds `MAX_XPL_DEPTH`,
/// BEFORE it reaches the parser. `roxmltree` is recursive-descent and its
/// depth-10 limit applies only to ENTITY expansion, so element nesting is
/// unbounded: a few hundred KB of well-formed XML — far under the read cap —
/// overflows the thread stack. That is a process ABORT, not an `Err` and not an
/// unwind, so neither the `Document::parse` fallback nor `catch_unwind` can
/// contain it; the only fix is to not hand the document over at all. A byte-size
/// cap is deliberately NOT added: file size is already bounded by the UDF read
/// path, and a size cap does not close this class (a 180 KB document already
/// aborts) — depth is the bound that matters.
///
/// This is a conservative scan, not a validator: it tracks `<name …>` /
/// `</name>` while skipping comments, CDATA, processing instructions and
/// declarations, and treats `<… />` as non-nesting. A miscount can only cost a
/// pathological document its fast path, and the failure mode is the existing
/// one — fall back to the clip-name heuristic.
fn xpl_depth_within_limit(text: &str) -> bool {
let b = text.as_bytes();
let mut i = 0usize;
let mut depth = 0usize;
// Index just past `needle`, or the end of input when unterminated.
let after = |from: usize, needle: &[u8]| -> usize {
b[from..]
.windows(needle.len())
.position(|w| w == needle)
.map_or(b.len(), |p| from + p + needle.len())
};
while i < b.len() {
if b[i] != b'<' {
i += 1;
continue;
}
match b.get(i + 1) {
Some(b'/') => {
depth = depth.saturating_sub(1);
i = after(i, b">");
}
Some(b'?') => i = after(i, b"?>"),
Some(b'!') if b[i..].starts_with(b"<!--") => i = after(i, b"-->"),
Some(b'!') if b[i..].starts_with(b"<![CDATA[") => i = after(i, b"]]>"),
// `<!DOCTYPE …>` and friends. An internal subset is not tracked;
// `allow_dtd` is off by default, so such a document is rejected by
// the parser anyway.
Some(b'!') => i = after(i, b">"),
_ => {
// Element start tag: walk to its `>`, ignoring `>` inside
// quoted attribute values, and note whether it self-closes.
let mut j = i + 1;
let mut quote = 0u8;
let mut prev = 0u8;
while j < b.len() {
let c = b[j];
if quote != 0 {
if c == quote {
quote = 0;
}
} else if c == b'"' || c == b'\'' {
quote = c;
} else if c == b'>' {
break;
}
prev = c;
j += 1;
}
if prev != b'/' {
depth += 1;
if depth > MAX_XPL_DEPTH {
return false;
}
}
i = j + 1;
}
}
}
true
}
/// Parse the Advanced-Content playlist into its titles. Elements are matched by
/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
/// Returns empty for a non-XML / non-playlist blob, or one nested deeper than
/// [`MAX_XPL_DEPTH`], so the caller falls back to the clip-name heuristic.
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
let text = String::from_utf8_lossy(xpl);
if !xpl_depth_within_limit(&text) {
return Vec::new();
}
let Ok(doc) = roxmltree::Document::parse(&text) else {
return Vec::new();
};
let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
// tickBase lives on <TitleSet> (default 60fps).
let tick_base = doc
.descendants()
.find(|n| local(n, "TitleSet"))
.and_then(|n| n.attribute("tickBase"))
.map(parse_tick_base)
.unwrap_or(60);
let mut titles = Vec::new();
for tnode in doc
.descendants()
.filter(|n| local(n, "Title"))
.take(MAX_XPL_TITLES)
{
let number = tnode
.attribute("titleNumber")
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(0);
let name = tnode
.attribute("displayName")
.or_else(|| tnode.attribute("id"))
.unwrap_or("")
.to_string();
let duration_secs = tnode
.attribute("titleDuration")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let mut clips = Vec::new();
for c in tnode
.descendants()
.filter(|n| local(n, "PrimaryAudioVideoClip"))
{
// Bounded by MAX_XPL_CLIPS_PER_TITLE. `descendants()` is deliberate
// — a real playlist wraps its clips in `<PrimaryAudioVideoClipList>`
// or an `ApplicationSegment`, so `children()` would miss them — but
// it also means a clip nested inside N ancestor `<Title>`s is
// collected N times over, and the depth guard still allows 32 such
// ancestors. `break` rather than dropping surplus: unlike the
// directory scan (which must keep reading to find the `.vti`),
// nothing later in THIS loop is needed, and stopping bounds the walk
// as well as the allocation. Chapters are collected by a separate
// iterator below, so they are unaffected.
if clips.len() >= MAX_XPL_CLIPS_PER_TITLE {
break;
}
let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
continue;
};
let begin_secs = c
.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let end_secs = c
.attribute("titleTimeEnd")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(begin_secs);
clips.push(XplClip {
evo,
begin_secs,
end_secs,
});
}
if clips.is_empty() {
continue;
}
// Bounded by MAX_XPL_CHAPTERS_PER_TITLE — the same `descendants()`
// amplification as the clip loop above, on the same crafted playlist.
let chapters = tnode
.descendants()
.filter(|n| local(n, "Chapter"))
.filter_map(|ch| {
ch.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
})
.take(MAX_XPL_CHAPTERS_PER_TITLE)
.collect();
titles.push(XplTitle {
number,
name,
duration_secs,
clips,
chapters,
});
}
titles
}
/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option<Vec<u8>> {
let dir = udf_fs.find_dir("/ADV_OBJ")?;
let name = dir.entries.iter().find_map(|e| {
let lower = e.name.to_ascii_lowercase();
(!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
})?;
udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
}
/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
/// physical extents, concatenate them in playback order, carry the title-time
/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
/// layer-break split onto one timeline), and attach the duration, name, and
/// chapters. A title whose clips resolve to no on-disc extents is skipped.
fn compose_xpl_titles(
reader: &mut dyn SectorSource,
xpl_titles: &[XplTitle],
clip_extents: &BTreeMap<String, (String, u64, Vec<Extent>)>,
unusable: &std::collections::HashSet<String>,
halt: Option<&crate::halt::Halt>,
) -> Result<Vec<DiscTitle>> {
let mut titles = Vec::new();
// One memo for the whole playlist: a playlist legitimately carries several
// titles over the same clip (angles, a branch, a seamless-join variant), and
// a crafted one can name a single `.evo` from all MAX_XPL_TITLES titles.
let mut probes = EvoProbeCache::default();
for t in xpl_titles {
// A clip that exists but has no truthful read plan poisons every
// title that names it: composing around it would emit a title short
// by that clip's bytes while its durations and chapter offsets still
// assume them.
if t.clips.iter().any(|c| unusable.contains(&c.evo)) {
continue;
}
let mut extents = Vec::new();
let mut size_bytes = 0u64;
let mut parts = Vec::new();
// A crafted `<PrimaryAudioVideoClip>` list can name the same `.evo`
// any number of times (XML has no fixed element count, unlike MPLS's
// binary PlayItem count). Mirrors bluray.rs's
// `first_ref = seen_clips.insert(...)` gate (`:113`/`:117`): push a
// clip's physical extents and size only the first time its key is
// seen, so a clip named N times contributes its extent list ONCE
// instead of N times over. The analogous key here is the `.evo`
// filename — this format has no clip_id, and `.evo` is what
// `clip_extents` is already keyed by.
let mut seen_evos: std::collections::HashSet<&str> = std::collections::HashSet::new();
for c in &t.clips {
let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
continue;
};
if seen_evos.insert(c.evo.as_str()) {
extents.extend_from_slice(exts);
size_bytes = size_bytes.saturating_add(*size);
}
parts.push(Clip {
feed_span: None,
clip_id: orig
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(orig)
.to_string(),
in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
duration_secs: (c.end_secs - c.begin_secs).max(0.0),
source_packets: 0,
});
}
if parts.is_empty() {
continue;
}
let streams = probes.streams(reader, &extents, halt)?;
let chapters = t
.chapters
.iter()
.enumerate()
.map(|(i, &ts)| Chapter {
time_secs: ts.max(0.0),
name: super::chapter_name(i),
})
.collect();
titles.push(DiscTitle {
// Language-neutral identifier (no user-facing English in the library):
// matches the UDF `TITLE_*` volume-label style. Apps localize display.
playlist: if t.name.is_empty() {
format!("TITLE_{}", t.number)
} else {
t.name.clone()
},
playlist_id: t.number,
duration_secs: t.duration_secs,
size_bytes,
clips: parts,
streams,
chapters,
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
}
Ok(titles)
}
impl Disc {
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
///
/// The main feature is authored as one or more `.evo` clips (a layer-break
/// split — `FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The
/// `HVA*.VTI` navigation file names every clip in authored order; this parses
/// it to concatenate the feature clips into ONE title (so the largest-title
/// pick gets the whole movie, not just part 1), emitting every other clip as
/// its own title. Falls back to one title per clip when the VTI is absent or
/// unparseable, so a disc with no readable navigation still enumerates.
/// `chapters`/duration are left empty pending deeper VTI parsing.
/// Cancellation: `halt` is polled once per clip and again before every
/// probe chunk, and a read that fails with [`Error::Halted`] — how a live
/// drive reports a Stop, since `Drive::checked_exec` fails every command
/// once its flag is set — is propagated rather than swallowed. `Halted` is
/// the only error this returns; every other read failure keeps its
/// best-effort behaviour. It has to be an error and not a short title
/// list: a cancelled enumeration that returned `Ok` would be
/// indistinguishable from a disc that genuinely holds fewer titles, and
/// the caller would cache and act on it.
pub(super) fn scan_hddvd_titles(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
halt: Option<&crate::halt::Halt>,
) -> Result<Vec<DiscTitle>> {
let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else {
return Ok(Vec::new());
};
// Snapshot clips (name, size) and the VTI navigation file. The `ts_dir`
// borrow must end before the `udf_fs` reads below re-borrow it.
let mut clips: Vec<(String, u64)> = Vec::new();
let mut vti_name: Option<String> = None;
for e in &ts_dir.entries {
if e.is_dir {
continue;
}
let lower = e.name.to_ascii_lowercase();
if lower.ends_with(HDDVD_CLIP_EXT) {
// Bounded by MAX_HDDVD_CLIPS: each clip taken here costs an ICB
// read to resolve its extents and (in the fallback path below) a
// full probe_evo_streams pass. Surplus entries are DROPPED rather
// than `break`ing the loop, so a crafted disc cannot hide the
// `.vti` navigation file behind a wall of `.evo` names.
if clips.len() < MAX_HDDVD_CLIPS {
clips.push((e.name.clone(), e.size));
}
} else if lower.ends_with(".vti") && vti_name.is_none() {
vti_name = Some(e.name.clone());
}
}
// Authored clip order from the VTI clip table (empty if no VTI).
//
// The VTI name came from `ts_dir.entries`, so a failed read here is a
// real I/O error (a scratched sector under the `.vti`), never an absent
// file. `.ok()` used to flatten the two — dropping the authored order
// with no diagnostic, so a split feature then composed from the per-clip
// heuristic and the operator was never told the authored order existed
// but could not be read. The clip-extent arms below log every read
// failure with its own code; this one now does too. The fallback itself
// is unchanged (no order => per-clip, exactly as an unauthored disc),
// because there is nothing to compose from without the table. Logging is
// exempt from the no-English rule (errors stay numeric).
let order: Vec<String> = match vti_name {
None => Vec::new(),
Some(n) => match udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")) {
Ok(bytes) => parse_vti_clip_order(&bytes),
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
vti = ?n,
code = e.code(),
"authored clip order unreadable; falling back to the per-clip heuristic"
);
Vec::new()
}
},
};
// Resolve each clip's physical extents once, keyed by lower-case name.
let mut clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = BTreeMap::new();
// Clips that EXIST but cannot be given a truthful read plan, because
// the file carries an unrecorded (never-written) extent — see
// `UdfFs::file_extents`. Kept apart from "did not resolve": a title
// built without one of these is short by that clip's runtime while
// still claiming its duration and size, which is data loss shaped
// like an ordinary rip. Any title naming one is dropped instead.
let mut unusable: std::collections::HashSet<String> = std::collections::HashSet::new();
for (name, size) in &clips {
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted);
}
let mut extents = Vec::new();
match udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) {
Ok(file_exts) => {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
extents.push(Extent {
start_lba: lba,
sector_count: sectors,
});
}
}
}
Err(crate::error::Error::UdfUnrecordedExtent { .. }) => {
// Say so. Marking the clip unusable drops every title that
// names it, and in the fallback case the OTHER part of a
// split feature is still offered standalone — so half a
// movie is presented as a whole one. Silently returning Ok
// with the feature missing is the shape of data loss this
// refusal exists to prevent, pointed the other way.
//
// The Blu-ray half of this same fix already warns; this
// file had no diagnostics at all. Logging is exempt from
// the crate's no-English rule (errors stay numeric).
//
// Named constant, not the literal 6017 this used to carry:
// a hardcoded code in the very file whose sibling arm was
// changed to stop doing that is the next drift waiting to
// happen, and the literal cannot follow a renumbering.
tracing::warn!(
target: "freemkv::disc",
clip = ?name,
code = crate::error::E_UDF_UNRECORDED_EXTENT,
"clip carries an unrecorded extent; dropping every title that names it"
);
unusable.insert(name.to_ascii_lowercase());
}
Err(crate::error::Error::Halted) => return Err(crate::error::Error::Halted),
// EVERY other failure means the same thing: no truthful read
// plan for this clip. A scratched sector under its ICB
// (DiscRead), an allocation-descriptor chain that never
// terminated (UdfAdChainTooLong), a file whose data is
// embedded rather than extent-mapped (UdfEmbeddedData) — all
// of them used to land in a bare `Err(_) => {}`: no log, and
// the clip NOT marked unusable, so a split feature still
// composed from FEATURE_1 alone and presented half a movie as
// a whole one. That is the very outcome the arm above was
// written to prevent, reachable through every error but one.
//
// The code logged is the error's own. Reusing 6017 here would
// account a scratched disc as an authoring hole.
//
// (`UdfNotFound` needs no special case: these names came from
// `ts_dir.entries`, so the lookup cannot miss.)
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
clip = ?name,
code = e.code(),
"clip extents could not be resolved; dropping every title that names it"
);
unusable.insert(name.to_ascii_lowercase());
}
}
// A clip that resolved with NO usable extent is unusable too, and
// for exactly the same reason as the `Err` arms above.
//
// Round 1 accounted for every `Err` from `file_extents` and left
// this route open: `Ok` can still yield an empty AD list, or a list
// every entry of which the `sectors > 0 && lba > 0` filter above
// discards (a zero-length placeholder AD, or one pointing at LBA
// 0 — see `UdfFs::file_extents`). The clip then entered NEITHER
// `clip_extents` NOR `unusable`, and nothing was logged. The
// composer's `any(|n| unusable.contains(..))` guard below therefore
// missed it and the `filter(|n| clip_extents.contains_key(..))`
// beside it quietly deleted the part: a `FEATURE_2.EVO` of size 0
// next to a healthy `FEATURE_1.EVO` composed a FEATURE title out of
// part one alone, still advertising the whole runtime, at rc=0,
// silently. That is strictly WORSE than the `Err` case this guard
// was built for — half a movie presented as a whole one — and it is
// reachable from an ordinary zero-byte file, no crafting needed.
//
// WHY THE BLU-RAY HALF DELIBERATELY DIFFERS. `bluray.rs` documents
// this same `Ok`-but-empty hole as a KNOWN GAP and leaves it open,
// and that decision still stands there — not here. Two things are
// different. (1) Cost: BD has no `unusable` set, so closing it
// there means a post-loop "every clip_id must appear in `spans`"
// invariant that DROPS the title, and it is not settled that an
// empty-but-Ok resolve is always a defect rather than a legitimate
// healthy-disc state; dropping healthy titles is worse than the
// gap. Here the set already exists, so refusing costs nothing new.
// (2) Consequence: on BD the clip is one PlayItem of a title that
// is otherwise whole; on HD-DVD the authored feature is COMPOSED
// from parts, so the missing one is silently spliced out of a title
// that keeps claiming the full runtime. Same hole, different price.
// The asymmetry is deliberate; it is written down here so the next
// audit reads a decision instead of finding an oversight.
//
// `insert` returning false means an `Err` arm above already logged
// this clip — no second line for one clip.
if extents.is_empty() {
if unusable.insert(name.to_ascii_lowercase()) {
// Not the neighbouring 6017: an empty AD list is not an
// unrecorded extent, and flattening the two would account a
// zero-byte file as an authoring hole.
tracing::warn!(
target: "freemkv::disc",
clip = ?name,
code = crate::error::E_UDF_NO_USABLE_EXTENT,
"clip resolved to no usable extent; dropping every title that names it"
);
}
} else {
clip_extents.insert(name.to_ascii_lowercase(), (name.clone(), *size, extents));
}
}
// Authoritative composition from the Advanced-Content playlist
// (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
// chapters, and title-time offsets. This is the primary path; the
// clip-name heuristic below is the fallback when the playlist is absent
// or unparseable (or resolves to no on-disc clips).
if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
let composed = compose_xpl_titles(
reader,
&parse_xpl_titles(&xpl),
&clip_extents,
&unusable,
halt,
)?;
if !composed.is_empty() {
return Ok(composed);
}
}
// Feature clips, in authored order, that actually resolved to extents.
// If ANY authored feature part has no truthful read plan, the composed
// feature title would silently omit that part's bytes — emit no
// composed feature at all rather than a short one. (The per-clip
// titles below are unaffected: each stands alone and an unusable clip
// simply yields no title.)
let feature: Vec<String> = if order
.iter()
.filter(|n| is_feature_clip(n))
.any(|n| unusable.contains(&n.to_ascii_lowercase()))
{
Vec::new()
} else {
order
.iter()
.filter(|n| is_feature_clip(n))
.filter(|n| clip_extents.contains_key(&n.to_ascii_lowercase()))
.cloned()
.collect()
};
let feature_set: std::collections::HashSet<String> =
feature.iter().map(|n| n.to_ascii_lowercase()).collect();
let mut titles = Vec::new();
let mut next_id = 0u16;
// One memo across the composed feature title and every per-clip title:
// nothing de-duplicates a FID's ICB LBA, so any number of directory
// entries can name ONE File Entry and resolve to identical extents.
let mut probes = EvoProbeCache::default();
// The composed feature title: concatenate its parts' extents in authored
// order. Streams are probed from the head (the first part). One `Clip` per
// part records the composition.
if !feature.is_empty() {
let mut extents = Vec::new();
let mut size_bytes = 0u64;
let mut parts = Vec::new();
for n in &feature {
if let Some((orig, size, exts)) = clip_extents.get(&n.to_ascii_lowercase()) {
extents.extend_from_slice(exts);
size_bytes = size_bytes.saturating_add(*size);
parts.push(Clip {
feed_span: None,
clip_id: orig
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(orig)
.to_string(),
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
});
}
}
let streams = probes.streams(reader, &extents, halt)?;
titles.push(DiscTitle {
playlist: "FEATURE".to_string(),
playlist_id: next_id,
duration_secs: 0.0,
size_bytes,
clips: parts,
streams,
chapters: Vec::new(),
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
next_id = next_id.saturating_add(1);
}
// Every remaining clip is its own title (unchanged behaviour). Iterated in
// directory order; when there is no VTI/feature this emits ALL clips.
for (name, _size) in &clips {
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted);
}
let key = name.to_ascii_lowercase();
if feature_set.contains(&key) {
continue;
}
let Some((orig, size, extents)) = clip_extents.get(&key) else {
continue;
};
// Probe the clip head for its elementary streams so the mux path
// builds a non-empty `pid_to_track` and actually routes packets.
let streams = probes.streams(reader, extents, halt)?;
let clip_id = orig
.rsplit_once('.')
.map(|(base, _)| base.to_string())
.unwrap_or_else(|| orig.clone());
titles.push(DiscTitle {
playlist: orig.clone(),
playlist_id: next_id,
duration_secs: 0.0,
size_bytes: *size,
clips: vec![Clip {
feed_span: None,
clip_id,
in_time: 0,
out_time: 0,
duration_secs: 0.0,
source_packets: 0,
}],
streams,
chapters: Vec::new(),
extents: extents.clone(),
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
next_id = next_id.saturating_add(1);
}
Ok(titles)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::udf::fixture::*;
/// Build a UDF with an `HVDVD_TS/` tree holding the listed `.evo` clips
/// (name, sector count, data LBA).
fn make_hddvd_fs(disc: &mut MemDisc, evos: &[(&str, u32, u32)]) -> crate::udf::UdfFs {
// ICBs are handed out from 100 upward, one per EVO, so the index IS
// the offset from that base.
let files: Vec<_> = evos
.iter()
.enumerate()
.map(|(i, (name, sectors, data_lba))| {
file(
name,
100 + i as u32,
*data_lba,
u64::from(*sectors) * 2048,
true,
)
})
.collect();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// HD-DVD's own enumerator yields one title per `.evo`, MpegPs container,
/// with real physical extents (mirrors the BD `.m2ts` extent path).
#[test]
fn scan_hddvd_titles_enumerates_evo_extents() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs(
&mut disc,
&[("FEATURE.EVO", 2000, 5000), ("BLOOP.EVO", 300, 9000)],
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(titles.len(), 2, "one title per .evo clip");
for t in &titles {
assert_eq!(
t.content_format,
ContentFormat::MpegPs,
"EVO is a program stream"
);
assert_eq!(t.extents.len(), 1);
}
let feature = titles.iter().find(|t| t.playlist == "FEATURE.EVO").unwrap();
assert_eq!(feature.extents[0].start_lba, PART_START + 5000);
assert_eq!(feature.extents[0].sector_count, 2000);
assert_eq!(
feature.clips[0].clip_id, "FEATURE",
"clip_id drops the extension"
);
}
// ── VTI playlist parsing + feature composition ────────────────────────
/// Build a synthetic `ADVANCED-VTS` VTI whose clip table lists `clips` in
/// order — one fixed-stride entry each, NUL-terminated name at `entry+0x42`.
fn synthetic_vti(clips: &[&str]) -> Vec<u8> {
let table_start = 0x200usize;
let mut v = vec![0u8; table_start + clips.len() * VTI_CLIP_ENTRY_STRIDE];
v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
for (i, name) in clips.iter().enumerate() {
let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
v[off..off + name.len()].copy_from_slice(name.as_bytes());
// The byte after the name stays 0 (NUL terminator).
}
v
}
#[test]
fn parse_vti_clip_order_reads_table_in_authored_order() {
let vti = synthetic_vti(&[
"DELOGO.EVO",
"FEATURE_1.EVO",
"FEATURE_2.EVO",
"TRAILER.EVO",
]);
let order = parse_vti_clip_order(&vti);
assert_eq!(
order,
vec![
"DELOGO.EVO".to_string(),
"FEATURE_1.EVO".to_string(),
"FEATURE_2.EVO".to_string(),
"TRAILER.EVO".to_string(),
]
);
// A non-VTI blob yields nothing.
assert!(parse_vti_clip_order(b"not a vti").is_empty());
}
#[test]
fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() {
// A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must
// not scan/collect them all (a CPU/memory amplification on a routine
// scan). The result is capped, and parsing stays fast.
let mut vti = Vec::with_capacity(1_000_000);
vti.extend_from_slice(HDDVD_VTI_MAGIC);
// ~160k tokens of the form "X.EVO\0" — well over the 8192 cap.
for _ in 0..(MAX_VTI_HITS * 20) {
vti.extend_from_slice(b"X.EVO\0");
}
let out = parse_vti_clip_order(&vti);
assert!(
out.len() <= MAX_VTI_HITS,
"collected hits capped at MAX_VTI_HITS, got {}",
out.len()
);
}
#[test]
fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() {
// Two residue buckets of EQUAL size must resolve to the SAME winner every
// call — `HashMap` iteration is randomized, so a `max_by_key` without a
// deterministic tie-break could pick a different bucket run-to-run on
// identical bytes. Build a VTI whose stray `.EVO` names tie the real
// table's bucket count, then assert the result is stable across calls.
let mut vti = vec![0u8; 0x600];
vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
let put = |v: &mut Vec<u8>, off: usize, name: &str| {
v[off..off + name.len()].copy_from_slice(name.as_bytes());
};
// Bucket A (residue 0x42): two names at stride 0x140.
put(&mut vti, 0x142, "A1.EVO");
put(&mut vti, 0x282, "A2.EVO");
// Bucket B (residue 0x50): two names — same count, different residue.
put(&mut vti, 0x150, "B1.EVO");
put(&mut vti, 0x290, "B2.EVO");
let first = parse_vti_clip_order(&vti);
for _ in 0..20 {
assert_eq!(
parse_vti_clip_order(&vti),
first,
"tie-break must be deterministic across repeated calls"
);
}
assert!(!first.is_empty());
}
#[test]
fn is_feature_clip_matches_the_feature_naming_variants() {
// Layer-break split (Shaun / Anchorman) and the divide form (Harry Potter).
assert!(is_feature_clip("FEATURE_1.EVO"));
assert!(is_feature_clip("FEATURE_2.EVO"));
assert!(is_feature_clip("feature.EVO"));
assert!(is_feature_clip("feature_Divide.EVO"));
// Extras are not the feature.
assert!(!is_feature_clip("TRAILER.EVO"));
assert!(!is_feature_clip("DLS_01.EVO"));
assert!(!is_feature_clip("EPK.EVO"));
}
/// The SECOND size accumulator. `compose_xpl_titles` sums an XPL's clip
/// sizes; this one sums the composed FEATURE title's parts, and it reads
/// the same disc-declared `u64` Information Length from the same
/// `clip_extents` map. A split feature whose two parts each declare a size
/// near `u64::MAX` overflows it exactly as the XPL path did.
///
/// Nothing reconciles the declared size against the extents that back it —
/// here each part declares `u64::MAX` while occupying ten sectors — because
/// the allocation descriptor's length field is only 32 bits wide, so the
/// disc can always claim more than it holds.
///
/// Mutation: restore `size_bytes += *size` in the feature-composition path
/// and this goes red (debug: attempt to add with overflow).
#[test]
fn scan_hddvd_feature_composition_saturates_absurd_disc_declared_sizes() {
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, u64::MAX, true),
file("FEATURE_2.EVO", 101, 8000, u64::MAX, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None)
.expect("a hostile size field must not fail the scan");
let feat = titles
.iter()
.find(|t| t.playlist == "FEATURE")
.expect("composed feature title");
assert_eq!(
feat.size_bytes,
u64::MAX,
"the part sizes must saturate at u64::MAX, never wrap to a small number"
);
}
#[test]
fn scan_hddvd_composes_split_feature_into_one_title() {
// A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus
// a TRAILER extra. The VTI names them in authored order; the scan must
// JOIN the two feature parts into one title (so the largest-title pick is
// the whole movie) and keep the trailer as its own title.
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
// One composed FEATURE title + the trailer.
assert_eq!(titles.len(), 2, "feature parts merged, trailer separate");
let feat = titles
.iter()
.find(|t| t.playlist == "FEATURE")
.expect("composed feature title");
assert_eq!(feat.clips.len(), 2, "both feature parts recorded");
assert_eq!(feat.size_bytes, (10 + 6) * 2048, "part sizes summed");
// Extents concatenated in authored order: FEATURE_1 (lba 5000) then
// FEATURE_2 (lba 8000) — the movie plays through in order.
assert_eq!(feat.extents.len(), 2);
assert_eq!(feat.extents[0].start_lba, PART_START + 5000);
assert_eq!(feat.extents[0].sector_count, 10);
assert_eq!(feat.extents[1].start_lba, PART_START + 8000);
assert_eq!(feat.extents[1].sector_count, 6);
// The largest title is the whole feature, not just part 1.
let largest = titles.iter().max_by_key(|t| t.size_bytes).unwrap();
assert_eq!(largest.playlist, "FEATURE");
assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO"));
}
/// A split feature (FEATURE_1 + FEATURE_2) where ONE part carries an
/// unrecorded extent must not be composed into a FEATURE title at all.
///
/// Composing around the unusable part is the dangerous outcome: the
/// title would play through as if it were the whole movie while silently
/// missing that part's runtime, and its size still counts it. The other
/// clips remain available as their own titles — refusing the composition
/// is not the same as refusing the disc.
#[test]
fn scan_hddvd_does_not_compose_a_feature_over_an_unrecorded_part() {
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
// FEATURE_2's ICB (laid at PART_START + 101): an unrecorded 2048-byte
// extent at LBA 7999, then its real content at 8000.
let mut icb = build_file_icb(6 * 2048, 8000, false);
icb[212..216].copy_from_slice(&16u32.to_le_bytes());
icb[216..220].copy_from_slice(&0x4000_0800u32.to_le_bytes()); // type 1
icb[220..224].copy_from_slice(&7999u32.to_le_bytes());
icb[224..228].copy_from_slice(&(6u32 * 2048).to_le_bytes()); // type 0
icb[228..232].copy_from_slice(&8000u32.to_le_bytes());
disc.put_bytes(PART_START + 101, &icb);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert!(
!titles.iter().any(|t| t.playlist == "FEATURE"),
"a feature part with no truthful read plan must not be composed \
into a title that silently omits it; got {:?}",
titles
.iter()
.map(|t| (&t.playlist, t.extents.len()))
.collect::<Vec<_>>()
);
// The clips that DID resolve are still offered on their own.
assert!(
titles.iter().any(|t| t.playlist == "TRAILER.EVO"),
"refusing the composition must not refuse the healthy clips"
);
assert!(
titles
.iter()
.all(|t| t.extents.iter().all(|e| e.start_lba != PART_START + 7999)),
"no title may read the unrecorded extent"
);
}
/// The refusal above must also be ACCOUNTED, with the unrecorded extent's
/// own code.
///
/// This warn site carried a hardcoded `code = 6017` literal — in the very
/// file whose sibling arm was changed to stop doing exactly that — and
/// nothing tested it, so neither the literal nor its absence broke
/// anything. Refusing a feature and saying nothing leaves the operator
/// with a disc that scanned "fine" and is quietly missing its main title.
///
/// Mutations: deleting the `tracing::warn!` (no event to find); restoring
/// the literal `6017` still passes here BY VALUE, which is the point —
/// what is pinned is `E_UDF_UNRECORDED_EXTENT`'s value reaching the log,
/// so a renumbering that the literal could not follow goes red; logging a
/// neighbouring code (e.g. `E_UDF_NO_USABLE_EXTENT`) goes red immediately.
#[test]
fn scan_hddvd_logs_an_unrecorded_feature_part_with_its_own_code() {
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
// Same crafted ICB as the test above: an unrecorded (type 1) extent
// ahead of FEATURE_2's real content.
let mut icb = build_file_icb(6 * 2048, 8000, false);
icb[212..216].copy_from_slice(&16u32.to_le_bytes());
icb[216..220].copy_from_slice(&0x4000_0800u32.to_le_bytes());
icb[220..224].copy_from_slice(&7999u32.to_le_bytes());
icb[224..228].copy_from_slice(&(6u32 * 2048).to_le_bytes());
icb[228..232].copy_from_slice(&8000u32.to_le_bytes());
disc.put_bytes(PART_START + 101, &icb);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let (_titles, events) = crate::testlog::capture(|| {
Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan")
});
let line = events
.iter()
.find(|e| e.field("clip") == Some("\"FEATURE_2.EVO\""))
.unwrap_or_else(|| panic!("the refused clip must be logged; got {events:?}"));
assert_eq!(line.target, "freemkv::disc");
assert_eq!(line.level, tracing::Level::WARN);
assert_eq!(
line.field("code"),
Some(crate::error::E_UDF_UNRECORDED_EXTENT.to_string().as_str()),
"{line:?}"
);
}
/// The `Ok`-but-empty twin of the test above, and the one that actually
/// bites on an ordinary disc: a feature part whose `file_extents` call
/// SUCCEEDS and yields nothing usable — here a zero-byte `FEATURE_2.EVO`,
/// no crafted ICB required — must refuse the composition too, and must say
/// so in the log.
///
/// Before this, the empty-`Ok` clip entered neither `clip_extents` nor
/// `unusable`, so the `any(|n| unusable.contains(..))` guard missed it and
/// the `filter(|n| clip_extents.contains_key(..))` beside it quietly
/// DELETED the part: the scan composed a `FEATURE` title out of part one
/// alone, still advertising itself as the feature, at rc=0, with no
/// diagnostic anywhere. Half a movie presented as a whole one — strictly
/// worse than the `Err` case the guard was built for, and reachable
/// without a hostile disc.
///
/// Mutations this catches, all of which the pre-fix code exhibited:
/// * dropping the `extents.is_empty()` branch entirely -> a `FEATURE`
/// title appears with one extent;
/// * inserting into `clip_extents` anyway -> same;
/// * marking the clip unusable but NOT logging -> the log assertion
/// fails (absence of a log is itself the defect: nothing else in the
/// system records that a part of the feature went missing);
/// * logging the neighbouring `E_UDF_UNRECORDED_EXTENT` instead of
/// `E_UDF_NO_USABLE_EXTENT` -> the code assertion fails, because a
/// zero-length file is not an authoring hole and triaging the two
/// together sends whoever reads it at the wrong population.
#[test]
fn scan_hddvd_does_not_compose_a_feature_over_a_part_with_no_usable_extent() {
let mut disc = MemDisc::new();
let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]);
let files = vec![
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
// Size 0 -> `file_extents` returns Ok with a zero-sector AD, which
// the `sectors > 0 && lba > 0` filter discards. No error is ever
// returned; the clip simply resolves to nothing.
file("FEATURE_2.EVO", 101, 8000, 0, true),
file("TRAILER.EVO", 102, 12000, 2 * 2048, true),
file_with("HVA00001.VTI", 103, 15000, vti, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let (titles, events) = crate::testlog::capture(|| {
Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan")
});
assert!(
!titles.iter().any(|t| t.playlist == "FEATURE"),
"a feature part that resolved to no usable extent must not be \
composed into a title that silently omits it while claiming the \
whole feature; got {:?}",
titles
.iter()
.map(|t| (&t.playlist, t.extents.len(), t.size_bytes))
.collect::<Vec<_>>()
);
// Refusing the composition is not refusing the disc.
assert!(
titles.iter().any(|t| t.playlist == "FEATURE_1.EVO"),
"the part that DID resolve is still offered standalone"
);
assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO"));
// ...and it is accounted, with its own code, naming the clip.
let line = events
.iter()
.find(|e| e.target == "freemkv::disc" && e.field("clip") == Some("\"FEATURE_2.EVO\""))
.unwrap_or_else(|| panic!("the dropped clip must be logged; got {events:?}"));
assert_eq!(line.level, tracing::Level::WARN);
assert_eq!(
line.field("code"),
Some(crate::error::E_UDF_NO_USABLE_EXTENT.to_string().as_str()),
"the condition's OWN code, not a neighbouring one: {line:?}"
);
}
// ── codec sniffing ────────────────────────────────────────────────────
#[test]
fn sniff_video_codec_recognizes_h264_vc1_mpeg2() {
// H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7.
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]),
Some(Codec::H264)
);
assert_eq!(
sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]),
Some(Codec::H264)
);
// VC-1 sequence-header BDU (0x0F).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]),
Some(Codec::Vc1)
);
// MPEG-2 sequence_header (0xB3).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]),
Some(Codec::Mpeg2)
);
// A slice/picture-only sample (no SPS/sequence) is indeterminate.
assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None);
assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None);
// Overlap regression: a picture_start_code (0x00) whose payload begins
// with 00 00 must advance a full 4 bytes so the code byte isn't re-read
// as the start of a new marker. Here the picture is followed by a real
// MPEG-2 sequence header — the scan must reach it cleanly and return
// Mpeg2 (and, critically, not be confused by the 1-byte overlap).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0xB3, 0x2D]),
Some(Codec::Mpeg2)
);
// A lone picture_start_code with a 00-heavy payload and no following real
// start code stays indeterminate (the overlap must not fabricate one).
assert_eq!(
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
None
);
// Every byte of the `00 00 01` marker is load-bearing: a near-miss
// that gets ANY one of the three bytes wrong must not be recognized.
assert_eq!(
sniff_video_codec(&[0x05, 0x00, 0x01, 0xB3]),
None,
"leading byte must be 0x00, not just any byte"
);
assert_eq!(
sniff_video_codec(&[0x00, 0x05, 0x01, 0xB3]),
None,
"second byte must be 0x00, not just any byte"
);
assert_eq!(
sniff_video_codec(&[0x00, 0xFF, 0x01, 0xB3]),
None,
"the middle byte of the marker must actually be checked, not skipped"
);
}
#[test]
fn sniff_audio_codec_recognizes_eac3_syncword() {
assert_eq!(
sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]),
Some(Codec::Ac3Plus)
);
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
// Both syncword bytes are required together: a lone 0x0B with no 0x77
// partner anywhere must not be recognized.
assert_eq!(
sniff_audio_codec(&[0x0B, 0x00, 0x0B, 0x01]),
None,
"0x0B alone (no 0x77 partner) is not the E-AC-3 syncword"
);
}
// ── EVO head probe → streams ──────────────────────────────────────────
/// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`.
fn pes(stream_id: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, stream_id];
let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(&[0x80, 0x00, 0x00]);
v.extend_from_slice(payload);
v
}
/// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on
/// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES
/// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword),
/// then program-end.
fn synthetic_evo() -> Vec<u8> {
let mut d = Vec::new();
// MPEG-2 pack header (14 bytes, stuffing 0).
d.extend_from_slice(&[
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
]);
// Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF
// range): SPS (type 7) + IDR (type 5) Annex-B.
let video_es = [
0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS
0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice
];
d.extend_from_slice(&pes(0xE2, &video_es));
// DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in
// — the demuxer strips 4 bytes, leaving the E-AC-3 syncword.
for sub in [0xC0u8, 0xC1] {
let audio_payload = [
sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2)
0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body
];
d.extend_from_slice(&pes(0xBD, &audio_payload));
}
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end
d
}
/// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes.
fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)],
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// An `.evo` whose ICB declares an UNRECORDED (ECMA-167 4/14.14.1.1
/// type-1) extent must yield NO title.
///
/// Those sectors were never written, so the clip's real content there is
/// zeros while the media holds whatever was left behind. Feeding them to
/// the mux as stream bytes splices undefined data into the middle of the
/// ripped `.evo`; leaving them out slides the rest of the file. Neither is
/// a rip, so the title is not offered at all — data loss must never look
/// like success.
#[test]
fn scan_hddvd_titles_refuses_a_clip_with_an_unrecorded_extent() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
// Control: unpatched, this fixture really does produce a title — so a
// later empty result means the hole was rejected, not that the
// fixture was inert.
assert_eq!(
Disc::scan_hddvd_titles(&mut disc, &udf, None)
.expect("scan")
.len(),
1,
"fixture must yield a title before the hole is introduced"
);
// Rewrite FEATURE.EVO's ICB (laid at PART_START + 100) with a
// two-descriptor short-AD list: an unrecorded 2048-byte extent at LBA
// 4999, then the real 4096-byte content at 5000.
let mut icb = build_file_icb(4096, 5000, false);
icb[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad: two short ADs
icb[216..220].copy_from_slice(&0x4000_0800u32.to_le_bytes()); // type 1, 2048 bytes
icb[220..224].copy_from_slice(&4999u32.to_le_bytes());
icb[224..228].copy_from_slice(&4096u32.to_le_bytes()); // type 0, 4096 bytes
icb[228..232].copy_from_slice(&5000u32.to_le_bytes());
disc.put_bytes(PART_START + 100, &icb);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert!(
titles.is_empty(),
"a clip with an unrecorded extent has no truthful read plan; got \
extents {:?}",
titles.iter().map(|t| &t.extents).collect::<Vec<_>>()
);
}
/// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and
/// two DD+ audio PES yields a title with the video track (canonical
/// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty
/// `streams` the mux path needs to route packets (the historical blocker).
#[test]
fn scan_hddvd_titles_probes_streams_from_evo_head() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo());
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(titles.len(), 1);
let t = &titles[0];
let video: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v),
_ => None,
})
.collect();
assert_eq!(video.len(), 1, "one video track probed");
assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264");
assert_eq!(
video[0].pid,
crate::mux::ps::DVD_VIDEO_PID,
"video routes to canonical PID"
);
let audio: Vec<_> = t
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) => Some(a),
_ => None,
})
.collect();
assert_eq!(audio.len(), 2, "both DD+ sub-streams probed");
assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus));
let pids: Vec<u16> = audio.iter().map(|a| a.pid).collect();
assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1");
}
/// A clip whose head carries no recognizable stream (unreadable /
/// ciphertext) leaves `streams` empty rather than fabricating one — the
/// title still enumerates (extents are real).
#[test]
fn scan_hddvd_titles_empty_streams_when_head_unrecognized() {
let mut disc = MemDisc::new();
// 4 KiB of junk with no PS start codes.
let junk = vec![0x55u8; 4096];
let udf = make_hddvd_fs_with_evo(&mut disc, &junk);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(titles.len(), 1);
assert!(
titles[0].streams.is_empty(),
"no recognizable stream → empty, not fabricated"
);
}
/// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given
/// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01
/// (PES_extension only), header_data_length=3, optional bytes
/// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1
/// video PES uses (ext 0x55).
fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, 0xFD];
let opt = [0x01u8, 0x81, stream_id_extension];
let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload
v.extend_from_slice(&len.to_be_bytes());
v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]);
v.extend_from_slice(&opt);
v.extend_from_slice(payload);
v
}
/// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext
/// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES.
fn synthetic_evo_vc1() -> Vec<u8> {
let mut d = Vec::new();
d.extend_from_slice(&[
0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8,
]);
// VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D).
let video_es = [
0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU
0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU
];
d.extend_from_slice(&pes_extended(0x55, &video_es));
let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD];
d.extend_from_slice(&pes(0xBD, &audio_payload));
d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]);
d
}
/// Build a bare PsPacket for the collect_es routing test.
fn ps_pkt(stream_id: u8, sub: Option<u8>, data: Vec<u8>) -> crate::mux::ps::PsPacket {
crate::mux::ps::PsPacket {
stream_id,
sub_stream_id: sub,
pts: None,
dts: None,
data,
source: None,
}
}
#[test]
fn collect_es_routes_only_vc1_0xfd_to_video() {
use crate::mux::ps::hddvd_extended_pid;
// The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio
// 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid
// with its PID or pollute the video sample — else the real video track is
// lost. (Routing 0xFD audio to its own track is deferred.)
let mut video = Vec::new();
let mut video_pid: Option<u16> = None;
let mut audio = BTreeMap::new();
// Audio-on-0xFD (ext 0x72) first — must be ignored by the video path.
collect_es(
&ps_pkt(0xFD, Some(0x72), vec![0xAA; 32]),
&mut video,
&mut video_pid,
&mut audio,
);
assert!(
video.is_empty(),
"0xFD audio sub-stream not routed to video"
);
assert_eq!(video_pid, None, "0xFD audio did not stamp the video PID");
// Then the real VC-1 video (ext 0x55).
collect_es(
&ps_pkt(0xFD, Some(0x55), vec![0xBB; 32]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(
video_pid,
Some(hddvd_extended_pid(0x55)),
"video PID stamped from the VC-1 0xFD sub-stream (0xFD55)"
);
assert_eq!(video.len(), 32, "VC-1 0xFD payload accumulated as video");
}
/// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields
/// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer
/// derives from the same stream_id_extension, so mux-time routing lines up.
#[test]
fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1());
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(titles.len(), 1);
let video: Vec<_> = titles[0]
.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v),
_ => None,
})
.collect();
assert_eq!(video.len(), 1, "one video track probed");
assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed");
assert_eq!(
video[0].pid,
crate::mux::ps::hddvd_extended_pid(0x55),
"VC-1 routes to the extended-stream-id PID 0xFD55"
);
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────
/// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
/// XML comment, a `<TitleSet tickBase="60fps">`, a MainMovie title whose main
/// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
/// with two chapters, and a separate deleted-scene title. Attribute order
/// varies (as it does across real discs).
const SYNTH_XPL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Playlist majorVersion="1" minorVersion="0" xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
<!-- Authored with TOSHIBA AdvMain -->
<TitleSet timeBase="60fps" tickBase="60fps" defaultLanguage="en">
<Title titleNumber="2" titleDuration="01:37:20:00" id="MainMovie" displayName="Main Movie">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:48:29:50" src="file:///dvddisc/HVDVD_TS/FEATURE_1.MAP" dataSource="Disc">
<Video track="1" mediaAttr="2"/>
<Audio track="1" streamNumber="1" description="English DD+"/>
</PrimaryAudioVideoClip>
<PrimaryAudioVideoClip titleTimeBegin="00:48:29:50" clipTimeBegin="00:00:00:00" titleTimeEnd="01:37:20:00" src="file:///dvddisc/HVDVD_TS/FEATURE_2.MAP" seamless="true">
<Video track="1" mediaAttr="2"/>
</PrimaryAudioVideoClip>
<ChapterList>
<Chapter displayName="Chapter 1" titleTimeBegin="00:00:00:00" />
<Chapter displayName="Chapter 2" titleTimeBegin="00:03:40:30" />
</ChapterList>
</Title>
<Title titleNumber="7" titleDuration="00:00:44:29" id="Deleted5" displayName="Deleted Scenes - Veronica Past">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:44:29" src="file:///dvddisc/HVDVD_TS/DEL5_VERONICAPAST.MAP" />
</Title>
</TitleSet>
</Playlist>"#;
#[test]
fn parse_timecode_hhmmssff_at_60fps() {
// 01:37:20:00 = 5840 s exactly.
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
// 00:48:29:50 = 48m29s + 50/60 frames.
let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
// MM:SS:FF short form (hours omitted).
assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
assert_eq!(parse_timecode("garbage", 60), None);
assert_eq!(parse_timecode("", 60), None);
}
#[test]
fn evo_from_src_maps_map_sidecar_to_evo() {
assert_eq!(
evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
Some("feature_1.evo")
);
// Already an EVO, or lowercase feature — normalise to lower `.evo`.
assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
assert_eq!(
evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
Some("feature_divide.evo")
);
// Empty stem → None (defensive against a malformed src).
assert_eq!(evo_from_src("file:///x/").as_deref(), None);
}
#[test]
fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");
let mm = &titles[0];
assert_eq!(mm.number, 2);
assert_eq!(mm.name, "Main Movie");
assert!(
(mm.duration_secs - 5840.0).abs() < 1e-6,
"97:20 from titleDuration"
);
// The layer-break split is ONE title with TWO clips, contiguous timeline.
assert_eq!(mm.clips.len(), 2);
assert_eq!(mm.clips[0].evo, "feature_1.evo");
assert_eq!(mm.clips[1].evo, "feature_2.evo");
assert!(
(mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
"FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
);
assert!(
mm.clips[1].begin_secs > 0.0,
"second clip carries a title-time offset"
);
assert_eq!(mm.chapters.len(), 2);
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
let del = &titles[1];
assert_eq!(del.name, "Deleted Scenes - Veronica Past");
assert_eq!(del.clips.len(), 1);
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
}
#[test]
fn parse_xpl_titles_returns_empty_on_non_xml() {
assert!(parse_xpl_titles(b"not xml at all").is_empty());
assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty());
}
/// A disc-supplied playlist nested far deeper than any real one must be
/// REFUSED BEFORE the XML parser sees it. `roxmltree` is recursive-descent
/// and its depth-10 limit covers only ENTITY expansion, so unbounded element
/// nesting blows the thread stack — an ABORT, which no `catch_unwind` and no
/// `Err` fallback can contain. ~50k levels is a few hundred KB of well-formed
/// XML, far under the read cap. The correct outcome is the same as any other
/// unusable playlist: an empty `Vec`, so the caller falls back to the
/// clip-name heuristic.
/// BREADTH, not depth: a flat playlist passes the nesting guard trivially
/// and can still declare a title per ~51 bytes, so a 64 MiB XPL (exactly
/// `udf::MAX_FILE_BYTES`) reaches ~1.3 million of them.
///
/// The cost is drive time, not memory: `compose_xpl_titles` runs
/// `probe_evo_streams` once per title and each probe reads real sectors,
/// so an uncapped count turns a scan the operator expects to take seconds
/// into tens of terabytes of optical reads. `bluray.rs` is not exposed to
/// this because its playlist count comes from the MPLS files present in
/// the UDF directory; an XPL DECLARES its titles, so nothing bounds them.
///
/// Mutation: delete the `.take(MAX_XPL_TITLES)` and this goes red at the
/// declared count.
#[test]
fn parse_xpl_titles_caps_a_playlist_declaring_absurdly_many_titles() {
const DECLARED: usize = MAX_XPL_TITLES + 500;
let mut xpl = String::with_capacity(DECLARED * 56 + 128);
xpl.push_str(r#"<?xml version="1.0" encoding="utf-8"?><Playlist><TitleSet>"#);
for _ in 0..DECLARED {
xpl.push_str(r#"<Title><PrimaryAudioVideoClip src="A.EVO"/></Title>"#);
}
xpl.push_str("</TitleSet></Playlist>");
let titles = parse_xpl_titles(xpl.as_bytes());
assert_eq!(
titles.len(),
MAX_XPL_TITLES,
"a playlist declaring {DECLARED} titles must be capped at \
{MAX_XPL_TITLES}; each surviving title costs a real probe_evo_streams \
pass over the medium"
);
}
/// The control: a realistic playlist keeps every title it declares.
#[test]
fn parse_xpl_titles_keeps_every_title_of_a_realistic_playlist() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert!(
!titles.is_empty() && titles.len() < MAX_XPL_TITLES,
"the synthetic real-world playlist must survive the cap untouched, \
got {} titles",
titles.len()
);
}
/// Build a playlist that nests `nesting` `<Title>` elements inside one
/// another and puts `clips` self-closing `<PrimaryAudioVideoClip>` elements
/// at the innermost level, plus `chapters` `<Chapter>` elements.
///
/// `parse_xpl_titles` collects a title's clips with `descendants()`, not
/// `children()`, so EVERY one of the `nesting` ancestors collects the SAME
/// innermost clip list — the amplification this exercises.
fn nested_title_xpl(nesting: usize, clips: usize, chapters: usize) -> String {
let mut xpl = String::with_capacity(nesting * 16 + clips * 40 + chapters * 40 + 128);
xpl.push_str(r#"<?xml version="1.0" encoding="utf-8"?><Playlist><TitleSet>"#);
for _ in 0..nesting {
xpl.push_str("<Title>");
}
for _ in 0..clips {
xpl.push_str(r#"<PrimaryAudioVideoClip src="A.EVO"/>"#);
}
for _ in 0..chapters {
xpl.push_str(r#"<Chapter titleTimeBegin="00:00:01:00"/>"#);
}
for _ in 0..nesting {
xpl.push_str("</Title>");
}
xpl.push_str("</TitleSet></Playlist>");
xpl
}
/// CLIPS PER TITLE — the axis the title cap, the depth cap and the
/// directory cap all leave open.
///
/// `MAX_XPL_TITLES` bounds how many titles a playlist may declare and
/// `MAX_XPL_DEPTH` bounds how deeply it may nest, but NEITHER bounds how
/// many `<PrimaryAudioVideoClip>` elements a single title collects. Worse,
/// the collector is `descendants()`, so one clip nested inside N ancestor
/// `<Title>`s is collected N times over — the clip count MULTIPLIES by the
/// nesting the depth guard still permits.
///
/// A 64 MiB XPL (`udf::MAX_FILE_BYTES`) holds ~1.7M clip elements at ~38
/// bytes each; with the 32 `<Title>` ancestors the depth cap still permits,
/// that is ~56 million heap-allocated `XplClip`s, and then as many `Clip`s
/// again in `compose_xpl_titles`. This uses small numbers that exercise the
/// same multiplication without a 64 MiB fixture: pre-fix it collected 756
/// clips per title, 18,900 across the 25 nested titles, from a document
/// declaring only 756 clip elements — an exact 25x, the nesting count.
///
/// Mutation: delete the `clips.len() >= MAX_XPL_CLIPS_PER_TITLE` break and
/// this goes red at the unamplified per-title count.
#[test]
fn parse_xpl_titles_caps_clips_per_title_against_descendant_amplification() {
const NESTING: usize = 25;
const CLIPS: usize = MAX_XPL_CLIPS_PER_TITLE + 500;
let xpl = nested_title_xpl(NESTING, CLIPS, 0);
let titles = parse_xpl_titles(xpl.as_bytes());
// The nesting itself must survive the depth guard, or this test would
// pass for the wrong reason (an empty fallback).
assert_eq!(
titles.len(),
NESTING,
"all {NESTING} nested <Title> elements must parse, so the \
amplification is really being exercised"
);
let total: usize = titles.iter().map(|t| t.clips.len()).sum();
for t in &titles {
assert!(
t.clips.len() <= MAX_XPL_CLIPS_PER_TITLE,
"a title collected {} clips, above the {MAX_XPL_CLIPS_PER_TITLE} \
cap; total across titles {total} (a 64 MiB XPL scales this to \
tens of millions)",
t.clips.len()
);
}
assert!(
total <= NESTING * MAX_XPL_CLIPS_PER_TITLE,
"aggregate clip count {total} must be bounded by titles x cap"
);
}
/// CHAPTERS PER TITLE — the same `descendants()` amplification on the
/// second unbounded `collect()` in the same loop. Bounded separately so
/// closing the clip axis does not simply move the attack next door.
///
/// Mutation: delete the `.take(MAX_XPL_CHAPTERS_PER_TITLE)` and this goes
/// red at the declared count.
#[test]
fn parse_xpl_titles_caps_chapters_per_title() {
const NESTING: usize = 25;
const CHAPTERS: usize = MAX_XPL_CHAPTERS_PER_TITLE + 500;
// One clip, so the title is kept (`clips.is_empty()` skips otherwise).
let xpl = nested_title_xpl(NESTING, 1, CHAPTERS);
let titles = parse_xpl_titles(xpl.as_bytes());
assert_eq!(titles.len(), NESTING);
for t in &titles {
assert!(
t.chapters.len() <= MAX_XPL_CHAPTERS_PER_TITLE,
"a title collected {} chapters, above the \
{MAX_XPL_CHAPTERS_PER_TITLE} cap",
t.chapters.len()
);
}
}
/// The control: a realistic multi-clip title still resolves EVERY one of its
/// clips and chapters. `SYNTH_XPL`'s main movie is a layer-break split — two
/// clips on one timeline — plus two chapters; losing either to the cap would
/// cost a genuine disc half its feature.
///
/// Mutation: set `MAX_XPL_CLIPS_PER_TITLE` to 1 and this goes red.
#[test]
fn parse_xpl_titles_keeps_every_clip_of_a_realistic_title() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert_eq!(titles.len(), 2);
assert_eq!(
titles[0].clips.len(),
2,
"the layer-break split must keep BOTH clips through the cap"
);
assert_eq!(titles[0].clips[0].evo, "feature_1.evo");
assert_eq!(titles[0].clips[1].evo, "feature_2.evo");
assert_eq!(
titles[0].chapters.len(),
2,
"both chapters must survive the chapter cap"
);
assert_eq!(titles[1].clips.len(), 1);
}
#[test]
fn parse_xpl_titles_refuses_deeply_nested_playlist() {
const DEPTH: usize = 50_000;
let mut xpl = String::with_capacity(DEPTH * 8 + 64);
xpl.push_str(r#"<?xml version="1.0" encoding="utf-8"?>"#);
for _ in 0..DEPTH {
xpl.push_str("<n>");
}
for _ in 0..DEPTH {
xpl.push_str("</n>");
}
assert!(
parse_xpl_titles(xpl.as_bytes()).is_empty(),
"a hostile nesting depth must fall back, not abort the process"
);
}
/// The depth guard must not reject real discs. A genuine `VPLST000.XPL` is a
/// handful of levels deep; this is the same document the parsing test uses,
/// plus self-closing tags, a comment and a processing instruction that the
/// pre-parse scanner has to account for without inflating its depth count.
#[test]
fn parse_xpl_titles_accepts_real_world_nesting_depth() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert_eq!(titles.len(), 2, "a real playlist still parses");
assert_eq!(titles[0].clips.len(), 2);
// Comments and processing instructions carry `<` and `/>` that a naive
// scanner would miscount as element nesting.
let decl = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
let tricky = SYNTH_XPL.replacen(
decl,
&format!("{decl}\n<!-- <a><a><a><a><a> -->\n<?authoring <b><b><b> ?>"),
1,
);
assert_eq!(parse_xpl_titles(tricky.as_bytes()).len(), 2);
}
/// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
/// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
fn make_hddvd_fs_xpl(
disc: &mut MemDisc,
evos: &[(&str, u32, u32)],
xpl: &[u8],
) -> crate::udf::UdfFs {
// ICBs are handed out from 100 upward, one per EVO, so the index IS
// the offset from that base.
let hv_files: Vec<_> = evos
.iter()
.enumerate()
.map(|(i, (name, sectors, data_lba))| {
file(
name,
100 + i as u32,
*data_lba,
u64::from(*sectors) * 2048,
true,
)
})
.collect();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: hv_files,
subdirs: vec![],
},
DirSpec {
name: "ADV_OBJ".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
subdirs: vec![],
},
],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// The authoritative path: when a VPLST000.XPL is present, titles come from
/// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
/// into ONE title with the real duration, name, chapters, and per-clip
/// title-time offsets — not the clip-name heuristic.
#[test]
fn scan_hddvd_composes_titles_from_xpl_playlist() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_xpl(
&mut disc,
&[
("FEATURE_1.EVO", 2000, 5000),
("FEATURE_2.EVO", 1800, 9000),
("DEL5_VERONICAPAST.EVO", 100, 12000),
],
SYNTH_XPL.as_bytes(),
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(
titles.len(),
2,
"the two playlist titles that resolve to clips"
);
let mm = titles
.iter()
.find(|t| t.playlist == "Main Movie")
.expect("MainMovie composed from the playlist");
assert_eq!(mm.playlist_id, 2);
assert!(
(mm.duration_secs - 5840.0).abs() < 1.0,
"97:20 duration from titleDuration, not 0/unknown"
);
assert_eq!(
mm.clips.len(),
2,
"layer-break split kept as ONE title, two clips"
);
// FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
// that splices it onto FEATURE_1's timeline instead of restarting at 0.
assert!(
mm.clips[1].in_time > 100_000_000,
"FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
mm.clips[1].in_time
);
assert_eq!(mm.chapters.len(), 2);
assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
// Both feature halves are in ONE title's extents.
assert!(!mm.extents.is_empty());
}
// ── parse_vti_clip_order: bound / cap / termination edge cases ────────
/// The outer scan cap (`MAX_VTI_HITS`) must be exact, not off-by-one. Every
/// entry here is stride-aligned so they ALL land in one residue bucket —
/// unlike the scattered-token cap test above, the cap is directly visible
/// in the output length (the total scanned-hit count IS the bucket size).
#[test]
fn parse_vti_clip_order_caps_hits_at_exact_boundary_same_residue() {
let table_start = 0x200usize;
let n = MAX_VTI_HITS + 50;
let mut v = vec![0u8; table_start + n * VTI_CLIP_ENTRY_STRIDE];
v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
for i in 0..n {
let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
v[off..off + b"X.EVO".len()].copy_from_slice(b"X.EVO");
}
let out = parse_vti_clip_order(&v);
assert_eq!(
out.len(),
MAX_VTI_HITS,
"the scan must stop at exactly MAX_VTI_HITS, not one past it"
);
}
/// A name-byte run that reaches the exact end of the buffer with NO NUL
/// terminator must not be read out of bounds — the inner scan (and the
/// nul-terminated check) must stop at the buffer boundary rather than
/// indexing one past it.
#[test]
fn parse_vti_clip_order_handles_unterminated_name_run_at_buffer_end() {
let mut v = HDDVD_VTI_MAGIC.to_vec();
v.extend_from_slice(b"TRAILING_JUNK_NO_TERMINATOR"); // all ascii-graphic, no NUL, ends at EOF
let out = parse_vti_clip_order(&v);
assert!(
out.is_empty(),
"unterminated trailing run yields no entries (and must not panic)"
);
}
/// A `.EVO`-suffixed name run followed by an in-bounds byte that is NOT a
/// NUL must not be treated as terminated — being merely in-bounds is not
/// the same as actually finding a NUL.
#[test]
fn parse_vti_clip_order_requires_actual_nul_terminator_not_just_in_bounds() {
let mut v = HDDVD_VTI_MAGIC.to_vec();
v.extend_from_slice(b"FEATURE.EVO");
v.push(0x01); // in-bounds terminator byte, but NOT a NUL
v.extend_from_slice(&[0u8; 16]);
let out = parse_vti_clip_order(&v);
assert!(
out.is_empty(),
"a non-NUL byte after .EVO must not count as terminated"
);
}
/// A NUL-terminated ascii run that does NOT end in ".EVO" must never be
/// collected, no matter how many repeat at a shared residue: nul-
/// termination and the `.EVO`-suffix check are independent gates, one must
/// not short-circuit the other away.
#[test]
fn parse_vti_clip_order_rejects_nul_terminated_names_without_evo_suffix() {
let mut v = HDDVD_VTI_MAGIC.to_vec();
for _ in 0..20 {
v.extend_from_slice(b"HELLO\0"); // nul-terminated, 5 bytes, not .EVO
}
let out = parse_vti_clip_order(&v);
assert!(
out.is_empty(),
"non-.EVO nul-terminated names must not be collected"
);
}
/// A short (<4-byte) nul-terminated name run must be rejected by the
/// length guard BEFORE the `.EVO`-suffix slice runs — slicing a name
/// shorter than 4 bytes at `name.len() - 4` would otherwise underflow.
/// Must not panic, and must not be collected.
#[test]
fn parse_vti_clip_order_short_circuits_length_check_before_slicing_short_names() {
let mut v = HDDVD_VTI_MAGIC.to_vec();
v.push(b' '); // non-name-byte separator: isolates "AB" from the magic run
v.extend_from_slice(b"AB\0"); // 2-byte name, under the 4-byte slice width
let out = parse_vti_clip_order(&v);
assert!(
out.is_empty(),
"short name is rejected without slicing/panicking"
);
}
// ── EVO_ES_SAMPLE_CAP / collect_es capping ─────────────────────────────
/// The documented sample cap is 128 KiB, i.e. `128 * 1024`.
#[test]
fn evo_es_sample_cap_is_128_kib() {
assert_eq!(EVO_ES_SAMPLE_CAP, 128 * 1024);
}
/// Plain-video-range (`0xE0..=0xEF`) samples stop growing once the buffer
/// has reached the cap — a subsequent packet must not push it past.
#[test]
fn collect_es_caps_video_sample_at_the_length_cap() {
use crate::consts::pes_stream_id::VIDEO;
let mut video = Vec::new();
let mut video_pid: Option<u16> = None;
let mut audio = BTreeMap::new();
collect_es(
&ps_pkt(VIDEO, None, vec![0xAA; EVO_ES_SAMPLE_CAP]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(video.len(), EVO_ES_SAMPLE_CAP);
collect_es(
&ps_pkt(VIDEO, None, vec![0xBB; 16]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(
video.len(),
EVO_ES_SAMPLE_CAP,
"no further growth once at the cap"
);
}
/// The VC-1 extended-stream-id (0xFD, ext 0x55) video branch has its own
/// cap check; it must behave identically to the plain-video branch.
#[test]
fn collect_es_caps_vc1_video_sample_at_the_length_cap() {
let mut video = Vec::new();
let mut video_pid: Option<u16> = None;
let mut audio = BTreeMap::new();
collect_es(
&ps_pkt(0xFD, Some(0x55), vec![0xAA; EVO_ES_SAMPLE_CAP]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(video.len(), EVO_ES_SAMPLE_CAP);
collect_es(
&ps_pkt(0xFD, Some(0x55), vec![0xBB; 16]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(
video.len(),
EVO_ES_SAMPLE_CAP,
"no further growth once at the cap (VC-1 0xFD branch)"
);
}
/// The per-sub-id audio branch has its own cap check; same requirement.
#[test]
fn collect_es_caps_audio_sample_at_the_length_cap() {
use crate::consts::pes_stream_id::PRIVATE_STREAM_1;
let mut video = Vec::new();
let mut video_pid: Option<u16> = None;
let mut audio = BTreeMap::new();
collect_es(
&ps_pkt(PRIVATE_STREAM_1, Some(0xC0), vec![0xAA; EVO_ES_SAMPLE_CAP]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(audio[&0xC0].len(), EVO_ES_SAMPLE_CAP);
collect_es(
&ps_pkt(PRIVATE_STREAM_1, Some(0xC0), vec![0xBB; 16]),
&mut video,
&mut video_pid,
&mut audio,
);
assert_eq!(
audio[&0xC0].len(),
EVO_ES_SAMPLE_CAP,
"no further growth once at the cap (audio branch)"
);
}
// ── read_adv_obj_xpl: prefix AND suffix are both required ──────────────
/// A file matching the `vplst` prefix but NOT the `.xpl` suffix must not
/// be adopted as the playlist — both conditions are independently
/// required, one must not be short-circuited away by the other.
#[test]
fn read_adv_obj_xpl_requires_both_vplst_prefix_and_xpl_suffix() {
let mut disc = MemDisc::new();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "ADV_OBJ".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with(
"VPLST_NOTES.TXT",
40,
4000,
b"not a playlist".to_vec(),
true,
)],
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert!(
read_adv_obj_xpl(&mut disc, &udf).is_none(),
"prefix match alone (not ending .xpl) must not select a file"
);
}
/// An Advanced-Content playlist title that names a clip with no truthful
/// read plan must be dropped whole, not composed from its other clips.
///
/// The unusable clip is absent from `clip_extents` (it never resolved),
/// which on its own just makes the loop skip it — emitting a title that
/// plays short by that clip while its chapter offsets and durations still
/// assume it. The `unusable` set is what distinguishes "this clip does not
/// exist" from "this clip exists and cannot be ripped truthfully".
#[test]
fn compose_xpl_titles_drops_a_title_naming_an_unusable_clip() {
let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [(
"a.evo".to_string(),
(
"A.EVO".to_string(),
1000u64,
vec![Extent {
start_lba: 1,
sector_count: 1,
}],
),
)]
.into_iter()
.collect();
// b.evo exists on the disc but carries an unrecorded extent.
let unusable: std::collections::HashSet<String> =
["b.evo".to_string()].into_iter().collect();
let xpl_titles = vec![XplTitle {
number: 1,
name: "T".to_string(),
duration_secs: 20.0,
clips: vec![
XplClip {
evo: "a.evo".to_string(),
begin_secs: 0.0,
end_secs: 10.0,
},
XplClip {
evo: "b.evo".to_string(),
begin_secs: 10.0,
end_secs: 20.0,
},
],
chapters: vec![],
}];
let mut disc = MemDisc::new();
let titles = compose_xpl_titles(&mut disc, &xpl_titles, &clip_extents, &unusable, None)
.expect("compose");
assert!(
titles.is_empty(),
"half of this title's runtime cannot be read, so composing it \
would ship a short title as a complete one; got {:?}",
titles
.iter()
.map(|t| (t.clips.len(), t.size_bytes))
.collect::<Vec<_>>()
);
}
// ── compose_xpl_titles: size/offset arithmetic ─────────────────────────
/// Direct unit test of the arithmetic composing a title from its XPL
/// clips: clip sizes are SUMMED (not multiplied), title-time in/out ticks
/// are `seconds * 45000` (not divided), and `duration_secs` is
/// `end - begin` (not `end + begin` or a division).
#[test]
fn compose_xpl_titles_sums_sizes_and_computes_in_out_times() {
let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [
(
"a.evo".to_string(),
(
"A.EVO".to_string(),
1000u64,
vec![Extent {
start_lba: 1,
sector_count: 1,
}],
),
),
(
"b.evo".to_string(),
(
"B.EVO".to_string(),
2000u64,
vec![Extent {
start_lba: 2,
sector_count: 1,
}],
),
),
]
.into_iter()
.collect();
let xpl_titles = vec![XplTitle {
number: 1,
name: "T".to_string(),
duration_secs: 10.0,
clips: vec![
XplClip {
evo: "a.evo".to_string(),
begin_secs: 2.0,
end_secs: 5.0,
},
XplClip {
evo: "b.evo".to_string(),
begin_secs: 5.0,
end_secs: 9.0,
},
],
chapters: vec![],
}];
let mut disc = MemDisc::new();
let titles = compose_xpl_titles(
&mut disc,
&xpl_titles,
&clip_extents,
&std::collections::HashSet::new(),
None,
)
.expect("compose");
assert_eq!(titles.len(), 1);
let t = &titles[0];
assert_eq!(t.size_bytes, 3000, "clip sizes summed, not multiplied");
assert_eq!(
t.clips[0].in_time,
(2.0f64 * 45000.0) as u32,
"in_time is begin_secs * 45000, not divided"
);
assert_eq!(
t.clips[0].out_time,
(5.0f64 * 45000.0) as u32,
"out_time is end_secs * 45000, not divided"
);
assert!(
(t.clips[0].duration_secs - 3.0).abs() < 1e-9,
"duration_secs is end_secs - begin_secs, not +/÷: got {}",
t.clips[0].duration_secs
);
assert!(
(t.clips[1].duration_secs - 4.0).abs() < 1e-9,
"second clip's duration is also end - begin: got {}",
t.clips[1].duration_secs
);
}
/// A title's `size_bytes` is a running sum of DISC-DECLARED clip sizes: the
/// `u64` UDF File Entry Information Length, which nothing cross-checks
/// against the extent list that file actually occupies. Two clips can
/// therefore each declare a size near `u64::MAX`.
///
/// Summed with a plain `+=`, that overflows: a panic in a debug build —
/// a crash on untrusted disc content, which this library must never do —
/// and a wrap to a small garbage total in release, which misreports the
/// title's size while looking entirely normal.
///
/// Mutation: restore `size_bytes += *size` in `compose_xpl_titles` and this
/// goes red (debug: attempt to add with overflow).
#[test]
fn compose_xpl_titles_saturates_absurd_disc_declared_clip_sizes() {
let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [
(
"a.evo".to_string(),
(
"A.EVO".to_string(),
u64::MAX,
vec![Extent {
start_lba: 1,
sector_count: 1,
}],
),
),
(
"b.evo".to_string(),
(
"B.EVO".to_string(),
u64::MAX,
vec![Extent {
start_lba: 2,
sector_count: 1,
}],
),
),
]
.into_iter()
.collect();
let xpl_titles = vec![XplTitle {
number: 1,
name: "T".to_string(),
duration_secs: 10.0,
clips: vec![
XplClip {
evo: "a.evo".to_string(),
begin_secs: 0.0,
end_secs: 5.0,
},
XplClip {
evo: "b.evo".to_string(),
begin_secs: 5.0,
end_secs: 10.0,
},
],
chapters: vec![],
}];
let mut disc = MemDisc::new();
let titles = compose_xpl_titles(
&mut disc,
&xpl_titles,
&clip_extents,
&std::collections::HashSet::new(),
None,
)
.expect("a hostile size field must not fail the scan either");
assert_eq!(titles.len(), 1);
assert_eq!(
titles[0].size_bytes,
u64::MAX,
"the sum must saturate at u64::MAX, never wrap to a small number"
);
}
/// A crafted Advanced-Content playlist can name the SAME `.evo` in
/// `<PrimaryAudioVideoClip>` many times over (the XML has no fixed element
/// count, unlike MPLS's binary PlayItem count). Each repeat must NOT push
/// another copy of that clip's extent list onto the title — mirroring
/// bluray.rs's `first_ref = seen_clips.insert(...)` gate (`:113`/`:117`),
/// which pushes a clip's extents only the first time its id is seen. Here
/// the analogous key is the `.evo` filename (this file has no clip_id;
/// `.evo` is what `clip_extents` is keyed by). Without the gate, extents
/// grows by the referenced clip's full extent list on EVERY repetition —
/// unbounded by the number of on-disc files, bounded only by playlist
/// size.
#[test]
fn compose_xpl_titles_dedups_repeated_clip_references_by_evo() {
let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [(
"a.evo".to_string(),
(
"A.EVO".to_string(),
1000u64,
vec![
Extent {
start_lba: 1,
sector_count: 1,
},
Extent {
start_lba: 2,
sector_count: 1,
},
Extent {
start_lba: 3,
sector_count: 1,
},
],
),
)]
.into_iter()
.collect();
const REPEATS: usize = 5000;
let clips: Vec<XplClip> = (0..REPEATS)
.map(|i| XplClip {
evo: "a.evo".to_string(),
begin_secs: i as f64,
end_secs: i as f64 + 1.0,
})
.collect();
let xpl_titles = vec![XplTitle {
number: 1,
name: "T".to_string(),
duration_secs: REPEATS as f64,
clips,
chapters: vec![],
}];
let mut disc = MemDisc::new();
let titles = compose_xpl_titles(
&mut disc,
&xpl_titles,
&clip_extents,
&std::collections::HashSet::new(),
None,
)
.expect("compose");
assert_eq!(titles.len(), 1);
assert_eq!(
titles[0].extents.len(),
3,
"the same .evo named {REPEATS} times must contribute its 3 extents \
ONCE, not {REPEATS} times over — got {} extents",
titles[0].extents.len()
);
}
/// Control for the de-dup above: a playlist naming several DISTINCT
/// clips (not repeats of one) must still resolve every one of them —
/// the de-dup key must not accidentally collapse different clips
/// together. If this test is made to fail by weakening the de-dup key
/// to something constant, that proves the key is doing real work rather
/// than vacuously deduping everything.
#[test]
fn compose_xpl_titles_resolves_all_distinct_clips_despite_dedup() {
let clip_extents: BTreeMap<String, (String, u64, Vec<Extent>)> = [
(
"a.evo".to_string(),
(
"A.EVO".to_string(),
1000u64,
vec![Extent {
start_lba: 1,
sector_count: 1,
}],
),
),
(
"b.evo".to_string(),
(
"B.EVO".to_string(),
2000u64,
vec![Extent {
start_lba: 2,
sector_count: 1,
}],
),
),
(
"c.evo".to_string(),
(
"C.EVO".to_string(),
3000u64,
vec![Extent {
start_lba: 3,
sector_count: 1,
}],
),
),
]
.into_iter()
.collect();
let xpl_titles = vec![XplTitle {
number: 1,
name: "T".to_string(),
duration_secs: 30.0,
clips: vec![
XplClip {
evo: "a.evo".to_string(),
begin_secs: 0.0,
end_secs: 5.0,
},
XplClip {
evo: "b.evo".to_string(),
begin_secs: 5.0,
end_secs: 15.0,
},
XplClip {
evo: "c.evo".to_string(),
begin_secs: 15.0,
end_secs: 30.0,
},
],
chapters: vec![],
}];
let mut disc = MemDisc::new();
let titles = compose_xpl_titles(
&mut disc,
&xpl_titles,
&clip_extents,
&std::collections::HashSet::new(),
None,
)
.expect("compose");
assert_eq!(titles.len(), 1);
assert_eq!(
titles[0].extents.len(),
3,
"three DISTINCT clips must all resolve — one extent each, not \
collapsed by an over-eager de-dup key"
);
assert_eq!(
titles[0].size_bytes, 6000,
"all three distinct sizes summed"
);
}
// ── Disc::scan_hddvd_titles: VTI-selection / extent-filter guards ──────
/// A file carrying the real `ADVANCED-VTS` magic but the WRONG extension
/// (not `.vti`) must never be adopted as the navigation file. Absent a
/// real `.vti` file, the scan must fall back to one title per clip (no
/// VTI-driven feature composition) rather than trusting a same-content
/// impostor by name-agnostic magic alone.
#[test]
fn scan_hddvd_titles_ignores_a_vti_look_alike_with_the_wrong_extension() {
let mut disc = MemDisc::new();
let vti_bytes = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"]);
let files = vec![
file_with("IMPOSTER.DAT", 90, 20000, vti_bytes, true),
file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true),
file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(
titles.len(),
2,
"no real .vti present -> no VTI-driven composition, one title per clip"
);
}
/// A clip whose extents cannot be resolved must drop the SPLIT FEATURE.
///
/// Catches reverting the `Err(e) => { warn; unusable.insert(..) }` arm in
/// `Disc::scan_hddvd_titles` back to the bare `Err(_) => {}` it replaced.
///
/// RED BEFORE GREEN, and TWO earlier attempts at this test did NOT go red.
/// The first asserted "no title names the broken clip" — that passes either
/// way, because a clip that resolves to no extents is never inserted into
/// `clip_extents` and so yields no per-clip title regardless. The second
/// fixed that but shipped no `.vti`: `order` is built solely from
/// `parse_vti_clip_order` of the navigation file, so with no `.vti` it is
/// EMPTY, `feature` is empty, and no composed title is ever built — the
/// assertion held vacuously with the fix reverted. Hence the synthetic
/// `HVA00001.VTI` below: it is what makes the composer run at all.
///
/// The defect lives one level up from the per-clip titles, in the composed
/// feature: `unusable` is what tells the composer that a part is MISSING
/// rather than merely absent, and the old bare `Err(_) => {}` populated it
/// for nothing but an unrecorded extent. So a scratched sector under
/// FEATURE_2's ICB (`Error::DiscRead`) left FEATURE_1 composing a title
/// named "FEATURE" by itself — half a movie offered as the whole one.
#[test]
fn scan_hddvd_titles_drops_a_split_feature_whose_part_cannot_be_read() {
let mut disc = MemDisc::new();
// The VTI clip table is the ONLY source of authored order, and the
// composed feature title exists only for clips it names. Without it
// this test cannot distinguish the fix from its absence.
let vti_bytes = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"]);
let files = vec![
file_with("HVA00001.VTI", 90, 20000, vti_bytes, true),
file("FEATURE_1.EVO", 100, 5000, 4 * 2048, true),
file("FEATURE_2.EVO", 101, 9000, 4 * 2048, true),
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
// Blank FEATURE_2's ICB (laid at PART_START + 101). Its descriptor tag
// is then 0, neither 261 nor 266 — what the parser sees when the sector
// holding an ICB cannot be read back intact. Deliberately NOT an
// unrecorded extent: that class was already handled.
disc.put_bytes(PART_START + 101, &[0u8; 2048]);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
assert!(
matches!(
udf.file_extents(&mut disc, "/HVDVD_TS/FEATURE_2.EVO"),
Err(crate::error::Error::DiscRead { .. })
),
"fixture must fail with DiscRead, not UdfUnrecordedExtent"
);
// Guard the guard: if the VTI ever stopped parsing, `order` would be
// empty and the assertion below would hold for the wrong reason.
assert_eq!(
parse_vti_clip_order(&synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"])),
vec!["FEATURE_1.EVO".to_string(), "FEATURE_2.EVO".to_string()],
"fixture VTI must yield both feature parts in authored order"
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
// FEATURE_1 alone must not be offered as the feature. It may still
// appear as its own standalone clip title — that stands alone and is
// honest — but nothing may present it as the composed whole.
let composed: Vec<_> = titles
.iter()
.filter(|t| t.clips.len() > 1 || t.playlist.eq_ignore_ascii_case("FEATURE"))
.map(|t| &t.playlist)
.collect();
assert!(
composed.is_empty(),
"a split feature missing one part must not compose; got {composed:?}"
);
}
/// A clip whose file has a zero-byte size (a degenerate/empty allocation:
/// its ICB's allocation descriptor has `data_len == 0`, the UDF AD-list
/// terminator, so `file_extents` yields no extent at all) must not
/// produce a title. NOTE: this exercises the *upstream* `data_len == 0`
/// terminator path in [`crate::udf::UdfFs::file_extents`], not the
/// `sectors > 0 && lba > 0` guard in `scan_hddvd_titles` itself — with
/// this fixture (`file_extents` never returns a `(lba, 0)` tuple, and
/// `PART_START` unconditionally makes every resolved `lba` positive)
/// that guard is unreachable in a divergent way; kept here as a
/// regression check on the zero-byte-file behavior in its own right.
#[test]
fn scan_hddvd_titles_excludes_a_clip_with_zero_sectors() {
let mut disc = MemDisc::new();
let files = vec![
file("REAL.EVO", 100, 5000, 4 * 2048, true), // ordinary, valid clip
file("BOGUS.EVO", 101, 9000, 0, true), // size 0 -> zero-sector extent
];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files,
subdirs: vec![],
}],
};
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(
titles.len(),
1,
"the zero-sector clip must not produce a title"
);
assert_eq!(titles[0].playlist, "REAL.EVO");
}
// ── probe_evo_streams: sector-cursor bookkeeping ───────────────────────
/// A zero-sector extent must be skipped outright, never entering the read
/// loop — `left` (an unsigned sector count) starting at 0 must gate the
/// loop closed. (A `left >= 0` tautology here would spin forever: `n`
/// would be pinned at 0, so neither `lba`, `left`, nor `remaining` would
/// ever change — a non-terminating loop reachable from a crafted extent
/// list.)
#[test]
fn probe_evo_streams_skips_a_zero_sector_extent_without_reading() {
let mut disc = MemDisc::new();
let extent = Extent {
start_lba: 500_000,
sector_count: 0,
};
let streams =
probe_evo_streams(&mut disc, std::slice::from_ref(&extent), None).expect("probe");
assert!(streams.is_empty(), "a zero-sector extent yields no streams");
}
/// The read cursor must advance FORWARD by each chunk's sector count, not
/// backward — real content living only in the second 512-sector (1 MiB)
/// chunk must be reached.
#[test]
fn probe_evo_streams_advances_lba_forward_across_chunk_reads() {
let mut disc = MemDisc::new();
let start_lba = 100_000u32;
// First chunk (512 sectors): inert filler, no start codes.
disc.put_bytes(start_lba, &vec![0x55u8; 512 * 2048]);
// Second chunk: the real EVO content (H.264 video PES).
let evo = synthetic_evo();
disc.put_bytes(start_lba + 512, &evo);
let extent = Extent {
start_lba,
sector_count: 512 + (evo.len() as u32).div_ceil(2048),
};
let streams =
probe_evo_streams(&mut disc, std::slice::from_ref(&extent), None).expect("probe");
let has_h264 = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264));
assert!(
has_h264,
"the second 1 MiB chunk must be read from the correct (forward) LBA"
);
}
/// The read loop must stop at the extent's DECLARED `sector_count` — data
/// living just past it must never be read (a buffer over-read past the
/// caller-supplied extent bound, on untrusted disc-layout input).
#[test]
fn probe_evo_streams_stops_reading_at_the_extents_declared_sector_count() {
let mut disc = MemDisc::new();
let start_lba = 200_000u32;
let declared_sectors = 4u32;
disc.put_bytes(start_lba, &vec![0x55u8; declared_sectors as usize * 2048]);
// Real H.264 PES data placed just PAST the declared extent — must
// never be read.
let evo = synthetic_evo();
disc.put_bytes(start_lba + declared_sectors, &evo);
let extent = Extent {
start_lba,
sector_count: declared_sectors,
};
let streams =
probe_evo_streams(&mut disc, std::slice::from_ref(&extent), None).expect("probe");
let has_h264 = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264));
assert!(
!has_h264,
"must not read past the extent's declared sector_count"
);
}
/// The total read budget (`EVO_PROBE_SECTORS`) must be enforced ACROSS
/// extents, not just within one — once it is exhausted by an earlier
/// extent, a later extent in the same probe must not be read at all.
#[test]
fn probe_evo_streams_caps_total_reads_across_extents_at_evo_probe_sectors() {
let mut disc = MemDisc::new();
let first_lba = 300_000u32;
disc.put_bytes(first_lba, &vec![0x55u8; EVO_PROBE_SECTORS as usize * 2048]);
// A second extent, following the first in the extents list: once the
// whole EVO_PROBE_SECTORS budget is spent on the first, this must
// never be reached.
let second_lba = first_lba + EVO_PROBE_SECTORS;
let evo = synthetic_evo();
disc.put_bytes(second_lba, &evo);
let extents = vec![
Extent {
start_lba: first_lba,
sector_count: EVO_PROBE_SECTORS,
},
Extent {
start_lba: second_lba,
sector_count: 10,
},
];
let streams = probe_evo_streams(&mut disc, &extents, None).expect("probe");
let has_h264 = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264));
assert!(
!has_h264,
"must not read past the total EVO_PROBE_SECTORS budget across extents"
);
}
// ── clip-name fallback: probe amplification ────────────────────────────
/// A [`SectorSource`] wrapper that counts how many reads START at each
/// watched LBA, plus the total sectors pulled off the medium. A probe of a
/// clip always issues its first read at the clip's first extent LBA, so the
/// hit count for that LBA IS the number of `probe_evo_streams` passes over
/// that clip.
struct ProbeCounter {
inner: MemDisc,
watch: Vec<u32>,
hits: Vec<u32>,
sectors_read: u64,
}
impl ProbeCounter {
fn new(inner: MemDisc, watch: Vec<u32>) -> Self {
let hits = vec![0; watch.len()];
Self {
inner,
watch,
hits,
sectors_read: 0,
}
}
}
impl SectorSource for ProbeCounter {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
if let Some(i) = self.watch.iter().position(|w| *w == lba) {
self.hits[i] += 1;
}
self.sectors_read += count as u64;
self.inner.read_sectors(lba, count, buf, recovery)
}
}
/// Lay an `HVDVD_TS/` holding exactly the given `(name, icb_lba, data_lba,
/// size)` clips — unlike `make_hddvd_fs`, the caller chooses each entry's
/// ICB, so many names can be pointed at ONE File Entry.
fn lay_hddvd_clips(disc: &mut MemDisc, specs: Vec<crate::udf::fixture::FileSpec>) {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: specs,
subdirs: vec![],
}],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
}
/// THE THIRD AMPLIFICATION AXIS on HD-DVD title composition, past both
/// existing guards. `xpl_depth_within_limit` bounds XML NESTING;
/// `MAX_XPL_TITLES` + the `.evo` de-dup bound what an XPL can DECLARE. Both
/// live on the playlist path — and an attacker simply omits `/ADV_OBJ`, so
/// `read_adv_obj_xpl` returns `None` and neither guard is ever consulted.
/// Control then reaches the clip-name FALLBACK, which emits one title per
/// `.evo` DIRECTORY ENTRY and probes each.
///
/// Nothing de-duplicates a FID's ICB LBA, so every name in a 1 MiB
/// directory (`udf::MAX_DIR_BYTES`, ~24,000 FIDs) can point at ONE File
/// Entry. Each resolves to the SAME extents and each costs a full
/// `EVO_PROBE_SECTORS` (16 MiB) probe: ~375 GiB of optical reads from a
/// directory that describes a single file.
///
/// Memoizing the probe on the resolved extent list collapses that to ONE
/// probe, because all of those names resolve to one extent list.
///
/// Mutation: drop the memo lookup in `EvoProbeCache::streams` (always
/// probe) and this goes red at `ENTRIES` probes.
#[test]
fn scan_hddvd_titles_probes_once_for_entries_resolving_to_identical_extents() {
const ENTRIES: usize = 512;
const SHARED_ICB: u32 = 100;
const SHARED_DATA: u32 = 5000;
let mut disc = MemDisc::new();
// Every FID names a different file and points at the SAME File Entry.
let specs: Vec<_> = (0..ENTRIES)
.map(|i| {
file(
&format!("C{i:04}.EVO"),
SHARED_ICB,
SHARED_DATA,
4 * 2048,
true,
)
})
.collect();
lay_hddvd_clips(&mut disc, specs);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let probe_lba = PART_START + SHARED_DATA;
let mut counter = ProbeCounter::new(disc, vec![probe_lba]);
let titles = Disc::scan_hddvd_titles(&mut counter, &udf, None).expect("scan");
assert_eq!(
counter.hits[0], 1,
"{ENTRIES} directory entries all resolving to the SAME extents must \
cost ONE probe_evo_streams pass, not one per entry; got {} probes",
counter.hits[0]
);
// And the whole scan's read volume stays bounded — the real currency
// here is drive time, not title count.
assert!(
counter.sectors_read < 4 * ENTRIES as u64,
"total sectors read must not scale with the entry count, got {}",
counter.sectors_read
);
assert!(!titles.is_empty(), "the clips still enumerate");
}
/// The clip cap and the per-probe read budget are ONE bound, not two, and
/// only their product is meaningful: `MAX_HDDVD_CLIPS` probes each cost up
/// to `EVO_PROBE_SECTORS`, so the pair fixes the worst-case drive time of a
/// `Disc::scan()`.
///
/// `EvoProbeCache` does not help against a crafted disc here: it keys on the
/// RESOLVED extent list, and distinct-but-overlapping extent lists all
/// reading one physical region miss the memo every time while every read
/// succeeds. So the product is genuinely reachable, and it is the number
/// that has to stay within what a scan can absorb.
///
/// 8 GiB is the ceiling asserted here. It is far above any genuine disc —
/// retail HD-DVDs carry tens of `.evo` clips, i.e. well under 1 GiB of
/// probing — and far below the 64 GiB the pair used to permit, which
/// exceeded the capacity of the dual-layer media this format tops out at
/// and took tens of minutes of optical reads.
///
/// This is a relationship, not a magic number: raising EITHER constant
/// without re-examining the other trips it.
#[test]
fn the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume() {
const CEILING_BYTES: u64 = 8 * 1024 * 1024 * 1024;
let budget = |cap: usize| {
cap as u64 * u64::from(EVO_PROBE_SECTORS) * crate::consts::SECTOR_BYTES as u64
};
// BOTH title-composition paths pay one probe per item, and a crafted
// disc reaches either one: the directory fallback when `/ADV_OBJ` is
// omitted, the playlist path when it is present. Capping only one moves
// the amplification next door.
for (name, cap) in [
("MAX_HDDVD_CLIPS", MAX_HDDVD_CLIPS),
("MAX_XPL_TITLES", MAX_XPL_TITLES),
] {
let worst_case = budget(cap);
assert!(
worst_case <= CEILING_BYTES,
"{name} ({cap}) x EVO_PROBE_SECTORS ({EVO_PROBE_SECTORS}) = \
{worst_case} bytes of probe reads, over the {CEILING_BYTES}-byte \
ceiling a scan can absorb"
);
}
}
/// CONTROL: memoization must not silently collapse DISTINCT clips. A
/// legitimate disc with several different `.evo` files still probes each
/// one, and each title keeps the streams of ITS OWN clip.
///
/// Mutation: make `EvoProbeCache`'s key a constant (ignore the extents) and
/// this goes red — the junk clip inherits the first clip's H.264 streams.
#[test]
fn scan_hddvd_titles_still_probes_each_distinct_clip() {
let mut disc = MemDisc::new();
let evo = synthetic_evo();
let junk = vec![0x55u8; 4 * 2048];
let (a_data, b_data, c_data) = (5000u32, 6000u32, 7000u32);
lay_hddvd_clips(
&mut disc,
vec![
file_with("A_REAL.EVO", 100, a_data, evo.clone(), true),
file_with("B_JUNK.EVO", 101, b_data, junk, true),
file_with("C_REAL.EVO", 102, c_data, evo, true),
],
);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let watch = vec![
PART_START + a_data,
PART_START + b_data,
PART_START + c_data,
];
let mut counter = ProbeCounter::new(disc, watch);
let titles = Disc::scan_hddvd_titles(&mut counter, &udf, None).expect("scan");
assert_eq!(
titles.len(),
3,
"every distinct clip still resolves a title"
);
// The substantive assertion first: each title carries the streams of ITS
// OWN clip. This is what a wrong memo key actually costs.
let by_name = |n: &str| titles.iter().find(|t| t.playlist == n).expect(n);
assert!(
by_name("A_REAL.EVO")
.streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
"the real clip keeps its own probed streams"
);
assert!(
by_name("B_JUNK.EVO").streams.is_empty(),
"the unrecognizable clip must NOT inherit another clip's streams"
);
assert!(
by_name("C_REAL.EVO")
.streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)),
"a third distinct clip is probed on its own extents"
);
assert_eq!(
counter.hits,
vec![1, 1, 1],
"each DISTINCT clip is still probed exactly once"
);
}
/// Memoization alone is NOT the whole fix. Collapsing identical extent
/// lists bounds the one-File-Entry attack, but distinct extent lists are
/// only as bounded as the File Entries an attacker cares to lay down — and
/// a File Entry is ONE sector. A 1 MiB directory's ~24,000 FIDs can each
/// point at their own 1-sector File Entry (a ~48 MiB image) declaring its
/// own huge extent, so every probe misses the memo and the amplification is
/// back. [`MAX_HDDVD_CLIPS`] is the bound that closes that.
///
/// Mutation: drop the `clips.len() < MAX_HDDVD_CLIPS` gate in the directory
/// scan and this goes red at `ENTRIES`.
#[test]
fn scan_hddvd_titles_caps_a_directory_declaring_absurdly_many_clips() {
const ENTRIES: usize = MAX_HDDVD_CLIPS + 300;
let mut disc = MemDisc::new();
// Each entry gets its OWN File Entry and its OWN data extent, so no two
// resolve to the same extent list and the memo never hits.
let specs: Vec<_> = (0..ENTRIES)
.map(|i| {
// ICBs live far past the directory's own FID data (~230 KB
// from LBA 21) so laying the FIDs cannot clobber them.
file(
&format!("C{i:05}.EVO"),
100_000 + i as u32,
1_000_000 + i as u32 * 8,
2048,
true,
)
})
.collect();
lay_hddvd_clips(&mut disc, specs);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(
titles.len(),
MAX_HDDVD_CLIPS,
"a directory declaring {ENTRIES} clips must be capped at \
{MAX_HDDVD_CLIPS}; each surviving clip costs a real \
probe_evo_streams pass over the medium"
);
}
/// The control for the cap: a realistic disc keeps every clip it carries.
#[test]
fn scan_hddvd_titles_keeps_every_clip_of_a_realistic_disc() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs(
&mut disc,
&[
("FEATURE_1.EVO", 2000, 5000),
("FEATURE_2.EVO", 1800, 9000),
("TRAILER.EVO", 300, 12000),
("DELOGO.EVO", 40, 13000),
],
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf, None).expect("scan");
assert_eq!(
titles.len(),
4,
"a real disc's clips must survive the cap untouched"
);
}
}