From 4876f61d472992e0bb9878fba7fa5474c417ba0b Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:02:03 -0700 Subject: [PATCH 1/4] bluray: 3D size follows the interleaved extents, meta title drops control characters; scan tests for skipped playlist, late Stop, nibble mapping --- src/disc/bluray.rs | 210 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 200 insertions(+), 10 deletions(-) diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 96c20036..650f16bc 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -190,7 +190,10 @@ impl Disc { // WHOLE-clip size/extents on purpose (no EP-map seek): out-of-mark // bytes are dropped downstream by PTS at the SeamPlan — see // `SeamPlan::place` (src/mux/timeline.rs) `raw_ns >= in_ns && <= out_ns`. - total_size += pkt_count as u64 * crate::consts::BD_SOURCE_PACKET_BYTES as u64; + let clpi_bytes = pkt_count as u64 * crate::consts::BD_SOURCE_PACKET_BYTES as u64; + // The .ssif interleaves the dependent view, so the CLPI count (base + // view only) under-sizes what the extents deliver. + let mut from_ssif = false; // Get stream file extents from UDF allocation descriptors (dual- // layer discs split files across layers). Normally `.m2ts`; AACS 2.1 @@ -211,6 +214,7 @@ impl Disc { let file_exts = match udf_fs.file_extents(reader, &ssif) { Ok(exts) => { is_3d = true; + from_ssif = true; Some(exts) } Err(e) => { @@ -282,11 +286,13 @@ impl Disc { push_extents(file_exts, &mut extents, &mut feed_pos); if feed_pos > span_start { spans.insert(play_item.clip_id.clone(), (span_start, feed_pos)); - // A damaged CLPI reports no packet count: size the clip by its extents. - if pkt_count == 0 { - total_size += feed_pos - span_start; - } } + // A damaged CLPI reports no packet count: size the clip by its extents. + total_size += if pkt_count == 0 || from_ssif { + feed_pos - span_start + } else { + clpi_bytes + }; } } @@ -341,8 +347,8 @@ impl Disc { measured_cicp: None, })), 2 | 5 => { - // Guard: if coding_type is a subtitle codec (PGS 0x90/0x91), - // this is a misaligned stream -- treat as subtitle, not audio + // Guard: if coding_type is the PGS subtitle codec (0x90), this + // is a misaligned stream -- treat as subtitle, not audio if matches!(codec, Codec::Pgs) { Some(Stream::Subtitle(SubtitleStream { pid: s.pid, @@ -501,7 +507,13 @@ impl Disc { if let Some(start) = xml.find("") { let s = start + "".len(); if let Some(end) = xml[s..].find("") { - let title = xml_text_decode(xml[s..s + end].trim()); + // Disc-authored text reaches terminals and file names: + // control characters (decoded from `` too) are dropped. + let title: String = xml_text_decode(xml[s..s + end].trim()) + .chars() + .filter(|c| !c.is_control()) + .collect(); + let title = title.trim().to_string(); if !title.is_empty() && title != "Blu-ray" { return Some(title); } @@ -515,8 +527,6 @@ impl Disc { } } -// Decode the five predefined XML entities and numeric char refs in element text, and unwrap a -// CDATA section; unknown or malformed references are kept literally. /// Append each usable `(lba, sectors)` to `extents`, advancing `feed_pos` by its bytes. An extent /// with no sectors or at LBA 0 occupies no readable bytes and is skipped without moving `feed_pos`. fn push_extents(file_exts: Vec<(u32, u32)>, extents: &mut Vec, feed_pos: &mut u64) { @@ -532,6 +542,8 @@ fn push_extents(file_exts: Vec<(u32, u32)>, extents: &mut Vec, feed_pos: } } +// Decode the five predefined XML entities and numeric char refs in element text, and unwrap a +// CDATA section; unknown or malformed references are kept literally. fn xml_text_decode(raw: &str) -> String { if let Some(inner) = raw .strip_prefix(" Vec { + let mut out = vec![3u8, 0x01]; + out.extend_from_slice(&pid.to_be_bytes()); + out.push(attrs.len() as u8); + out.extend_from_slice(attrs); + out + } + + /// The MPLS format/rate nibbles reach the right `Stream` fields, and the + /// secondary-video (6) and Dolby Vision EL (7) categories are kept as video. + #[test] + fn parse_playlist_maps_format_rate_nibbles_and_secondary_video() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (1, 1, 0, 0, 0, 1, 0, 1), + &[ + se_raw(0x1011, &[0x1B, 0x63]), // 1080p, 25 fps + se_raw(0x1100, &[0x81, 0x64, b'e', b'n', b'g']), // 5.1, 96 kHz + // Secondary video carries audio-ref and PG-ref counts after the entry. + [se_raw(0x1B00, &[0x1B, 0x63]), vec![0u8; 4]].concat(), + se_raw(0x1015, &[0x24, 0x63]), + ], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls) + .expect("scan") + .expect("title"); + let videos: Vec<_> = t.video_streams().collect(); + let audio = t.audio_streams().next().expect("audio"); + assert_eq!(videos.len(), 3, "primary, secondary and EL video kept"); + assert_eq!(videos[0].resolution, Resolution::R1080p); + assert_eq!(videos[0].frame_rate, FrameRate::F25); + assert_eq!(audio.channels, AudioChannels::Surround51); + assert_eq!(audio.sample_rate, SampleRate::S96); + } + + /// One unreadable playlist is skipped with a warning; the healthy one survives. + #[test] + fn scan_bluray_titles_skips_an_unreadable_playlist() { + struct FailingReader<'a> { + inner: &'a mut MemDisc, + fail_lba: u32, + } + impl SectorSource for FailingReader<'_> { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + if lba == self.fail_lba { + return Err(Error::DiscRead { + sector: lba as u64, + status: Some(0x02), + sense: None, + }); + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + let mut disc = MemDisc::new(); + let udf = two_playlist_bd_fs(&mut disc); + let mut reader = FailingReader { + inner: &mut disc, + fail_lba: PART_START + 30000, // 00800.mpls's data extent + }; + let titles = Disc::scan_bluray_titles(&mut reader, &udf, None).expect("scan survives"); + assert_eq!(titles.len(), 1); + assert_eq!(titles[0].playlist, "00801.mpls"); + } + + /// A Stop that lands during the final playlist's reads is `Halted`, not a + /// silently truncated list. + #[test] + fn scan_bluray_titles_polls_halt_after_the_last_playlist() { + struct CancellingReader<'a> { + inner: &'a mut MemDisc, + halt: crate::halt::Halt, + cancel_lba: u32, + } + impl SectorSource for CancellingReader<'_> { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + if lba == self.cancel_lba { + self.halt.cancel(); + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + let mut disc = MemDisc::new(); + let udf = two_playlist_bd_fs(&mut disc); + let halt = crate::halt::Halt::new(); + let mut reader = CancellingReader { + inner: &mut disc, + halt: halt.clone(), + cancel_lba: PART_START + 40000, // 00801.mpls, the last playlist + }; + let res = Disc::scan_bluray_titles(&mut reader, &udf, Some(&halt)); + assert!(matches!(res, Err(Error::Halted)), "got {res:?}"); + } + + /// Control characters in the disc-authored title never reach a front end, + /// whether raw or written as a character reference. + #[test] + fn read_meta_title_drops_control_characters() { + let mut disc = MemDisc::new(); + let xml = b"AB\x07C".to_vec(); + let dl = DirSpec { + name: "DL".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with("bdmt_eng.xml", 104, 50000, xml, false)], + subdirs: vec![], + }; + let meta = DirSpec { + name: "META".to_string(), + icb_lba: 28, + dir_data_lba: 29, + files: Vec::new(), + subdirs: vec![dl], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![meta], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::read_meta_title(&mut disc, &udf), + Some("A[2JBC".to_string()) + ); + } + // --------------------------------------------------------------- // Tests: chapters // --------------------------------------------------------------- From 389340bb92e9b8eb9d212acdabb8d30ee7b880f1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:02:03 -0700 Subject: [PATCH 2/4] extract: a File Entry that fails after listing loses that file only; loss, progress and blank accounting tests --- src/disc/extract.rs | 246 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 228 insertions(+), 18 deletions(-) diff --git a/src/disc/extract.rs b/src/disc/extract.rs index cdc9cece..037a4b83 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -102,8 +102,8 @@ struct PlannedFile { /// ECMA-167 4/14.14.1.1 type-1 extent is allocated but not recorded: it /// occupies the file's byte space and its contents are zeros). extents: Vec, - /// A bus-encrypted stream file the reader's bus map could not locate: it - /// cannot be de-bussed, so it is never read or written, only counted lost. + /// A file whose data cannot be located (an unreadable File Entry, or a bus-encrypted + /// stream file the reader's bus map could not map): never read or written, only counted lost. unmapped: bool, } @@ -441,26 +441,58 @@ fn plan_tree( unmapped: true, }); } else { - let inline = fs.inline_data_at(reader, entry.meta_lba)?; - let extents = if inline.is_some() { - Vec::new() - } else { - fs.extents_abs_at(reader, entry.meta_lba)? - }; - files.push(PlannedFile { - host_rel: child_rel, - disc_name: entry.name.clone(), - size: entry.size, - inline, - extents, - unmapped: false, - }); + let located = fs + .inline_data_at(reader, entry.meta_lba) + .and_then(|inline| { + let extents = if inline.is_some() { + Vec::new() + } else { + fs.extents_abs_at(reader, entry.meta_lba)? + }; + Ok((inline, extents)) + }); + match located { + Ok((inline, extents)) => files.push(PlannedFile { + host_rel: child_rel, + disc_name: entry.name.clone(), + size: entry.size, + inline, + extents, + unmapped: false, + }), + // A Stop, a dead bus or a gone source ends the run; it is not a damaged entry. + Err(e) + if matches!(e, Error::Halted) + || e.is_scsi_transport_failure() + || e.is_source_terminated() => + { + return Err(e); + } + // A damaged File Entry loses that one file, not the tree around it. + Err(e) => { + tracing::warn!( + target: "freemkv::extract", + file = %child_rel.display(), + code = e.code(), + "file entry unreadable; file lost whole" + ); + files.push(PlannedFile { + host_rel: child_rel, + disc_name: entry.name.clone(), + size: entry.size, + inline: None, + extents: Vec::new(), + unmapped: true, + }); + } + } } } Ok(()) } -// Accounts an unmapped stream file as lost whole: nothing is written for it on the host. +// Accounts a file with no locatable data (an unreadable File Entry, or a bus-encrypted stream +// file that cannot be mapped to de-bus) as lost whole: nothing is written for it on the host. fn unmapped_file( pf: &PlannedFile, total_bytes: u64, @@ -471,7 +503,7 @@ fn unmapped_file( tracing::warn!( target: "freemkv::extract", file = %pf.host_rel.display(), - "bus-encrypted stream file cannot be located to de-bus; not extracted" + "file cannot be located on the disc; not extracted" ); *done_bytes = done_bytes.saturating_add(pf.size); *done_unreadable = done_unreadable.saturating_add(pf.size); @@ -2515,6 +2547,184 @@ mod tests { assert!(!res.complete && !res.halted); } + // A reader that serves one sector once (the tree listing reads each File Entry for its + // size), then fails it with `status`. + struct FailAt(MemDisc, u32, Option, u32); + impl SectorSource for FailAt { + fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result { + if lba == self.1 { + self.3 += 1; + } + if lba == self.1 && self.3 > 1 { + return Err(Error::DiscRead { + sector: lba as u64, + status: self.2, + sense: None, + }); + } + self.0.read_sectors(lba, n, buf, r) + } + } + + fn two_clip_disc() -> MemDisc { + two_clip_disc_with_first_unmapped().0 + } + + // One damaged File Entry loses that file only; the rest of the tree extracts. + #[test] + fn an_unreadable_file_entry_loses_that_file_not_the_tree() { + let mut disc = FailAt(two_clip_disc(), PART_START + 24, Some(0x02), 0); + let out = TmpDir::new("bad_fe"); + let res = clear_disc() + .extract_tree( + &mut disc, + out.path(), + &ExtractOptions::default(), + &crate::ctx::Ctx::default(), + ) + .expect("extract"); + assert_eq!( + read_out(out.path(), "BDMV/STREAM/00002.m2ts"), + Some(vec![0x22; 3 * 2048]) + ); + assert!(read_out(out.path(), "BDMV/STREAM/00001.m2ts").is_none()); + assert_eq!(res.bytes_unreadable, 3 * 2048); + assert!(!res.complete && !res.halted); + } + + // A dead bus while locating a file is not a damaged entry: the run fails. + #[test] + fn a_transport_failure_locating_a_file_entry_fails_the_run() { + let status = Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE); + let mut disc = FailAt(two_clip_disc(), PART_START + 24, status, 0); + let out = TmpDir::new("dead_bus_fe"); + let r = clear_disc().extract_tree( + &mut disc, + out.path(), + &ExtractOptions::default(), + &crate::ctx::Ctx::default(), + ); + assert!(r.is_err_and(|e| e.is_scsi_transport_failure())); + } + + // A holed file feeds the run's loss counters and the live progress split: the + // unreadable bytes are not counted as good. + #[test] + fn a_hole_reaches_the_loss_stats_and_the_progress_split() { + let seen = std::sync::Arc::new(std::sync::Mutex::new((0u64, 0u64))); + let tap = seen.clone(); + let ctx = crate::ctx::Ctx::new(crate::halt::Halt::new()).with_events(std::sync::Arc::new( + move |e: &crate::event::Event<'_>| { + if let crate::event::Event::Pass(p) = e { + *tap.lock().unwrap() = (p.bytes_good_total, p.bytes_unreadable_total); + } + }, + )); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "STREAM".to_string(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![ + file("00001.m2ts", 24, 5000, vec![0x11; 3 * 2048], true), + file("00002.m2ts", 25, 6000, vec![0x22; 6 * 2048], true), + ], + subdirs: vec![], + }], + }], + }; + let mut disc = build_disc(root); + for i in 0..3u32 { + disc.bad.insert(PART_START + 5000 + i); // the first clip's whole extent + } + let out = TmpDir::new("hole_stats"); + clear_disc() + .extract_tree(&mut disc, out.path(), &ExtractOptions::default(), &ctx) + .expect("extract"); + let loss = ctx.stats.snapshot(); + assert_eq!(loss.bytes_lost, 3 * 2048); + assert!(loss.read_skips >= 1); + assert_eq!(*seen.lock().unwrap(), (6 * 2048, 3 * 2048)); + } + + // A damaged AACS unit the reader blanks is unreadable bytes, not good ones. + #[test] + fn a_blanked_aacs_unit_is_counted_unreadable() { + const DATA: u32 = 5000; + let key = [0u8; 16]; + let mut content = encrypt_aacs_unit(&key, 0xA1); + let mut damaged = vec![0x55u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + damaged[0] |= 0xC0; // flagged encrypted, but no key opens it + content.extend_from_slice(&damaged); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "STREAM".to_string(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![file("00001.m2ts", 24, DATA, content, true)], + subdirs: vec![], + }], + }], + }; + let mut disc = build_disc(root); + let d = aacs_disc(); + let a = PART_START + DATA; + let set = crate::keys::KeyRing::keyed_for_test(&d, key, &[(a, a + 6)]); + let opts = ExtractOptions { + keys: Some(&set), + ..Default::default() + }; + let out = TmpDir::new("blanked_unit"); + let res = d + .extract_tree(&mut disc, out.path(), &opts, &crate::ctx::Ctx::default()) + .expect("extract"); + let unit = crate::aacs::content::ALIGNED_UNIT_LEN as u64; + assert_eq!(res.bytes_unreadable, unit); + assert_eq!(res.files[0].bytes_good, unit); + assert!(!res.complete); + } + + // The key set's loud stop is never a hole: the run ends with it. + #[test] + fn a_missing_disc_key_stops_the_run_instead_of_holing_the_unit() { + struct KeyMissingAt(MemDisc, u32); + impl SectorSource for KeyMissingAt { + fn read_sectors(&mut self, lba: u32, n: u16, b: &mut [u8], r: bool) -> Result { + if lba == self.1 { + return Err(Error::WholeDiscKeyMissing); + } + self.0.read_sectors(lba, n, b, r) + } + } + let mut disc = KeyMissingAt(two_clip_disc(), PART_START + 5000); + let out = TmpDir::new("key_missing"); + let r = clear_disc().extract_tree( + &mut disc, + out.path(), + &ExtractOptions::default(), + &crate::ctx::Ctx::default(), + ); + assert!(matches!(r, Err(Error::WholeDiscKeyMissing)), "{r:?}"); + } + // A Stop at the lost file's report ends the run there, like any other file. #[test] fn progress_stop_on_an_unmapped_stream_file_halts_the_run() { From 82b2dd6cd233bdcc37216fc0e9e672f883080352 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:02:03 -0700 Subject: [PATCH 3/4] disc tests: DVD nav join, halt polls, HD DVD Stop handling, PGS LBA bound, forced flag, unreadable MKB and cert --- src/disc/dvd.rs | 108 +++++++++++++++++++++++++++++ src/disc/dvd_audio_probe.rs | 1 + src/disc/hddvd.rs | 129 +++++++++++++++++++++++++++++++++++ src/disc/pgs_forced_probe.rs | 38 +++++++++++ src/disc/profile.rs | 20 ++++++ src/disc/scan_order_tests.rs | 34 +++++++++ 6 files changed, 330 insertions(+) diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 8e523415..6150f679 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -1658,6 +1658,11 @@ mod tests { sub.codec_data.is_some(), "non-zero palette must yield codec_data" ); + let idx = String::from_utf8(sub.codec_data.clone().expect("codec_data")).expect("utf8"); + assert!( + idx.starts_with("size: 720x480\npalette: "), + "the .idx carries the NTSC coded frame size, then the palette: {idx:?}" + ); } /// Multiple titles in one VTS each become their own DiscTitle with a @@ -1774,6 +1779,109 @@ mod tests { assert_eq!(titles[1].playlist_id, 2); } + // The nav target joins on the VTS number AND the in-set title number: VTS 2 also has an + // in-set title 1, which must not take the promotion from VTS 1's. + #[test] + fn scan_dvd_titles_nav_target_is_matched_by_vts_number() { + let mut disc = MemDisc::new(); + let mut vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]); + stamp_first_play_jumptt(&mut vmg, 1); + let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false); + let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false); + let udf = build_video_ts_fs( + &mut disc, + &[ + FileSpec { + name: "VIDEO_TS.IFO".into(), + icb_lba: 60, + data_lba: 5000, + contents: vmg, + }, + FileSpec { + name: "VTS_01_0.IFO".into(), + icb_lba: 62, + data_lba: 6000, + contents: vts1, + }, + FileSpec { + name: "VTS_02_0.IFO".into(), + icb_lba: 64, + data_lba: 7000, + contents: vts2, + }, + ], + ); + let (titles, nav_feature) = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan"); + assert_eq!(titles.len(), 2); + assert_eq!(nav_feature, Some(1)); + } + + fn one_title_dvd(disc: &mut MemDisc) -> udf::UdfFs { + build_video_ts_fs( + disc, + &[ + FileSpec { + name: "VIDEO_TS.IFO".into(), + icb_lba: 60, + data_lba: 5000, + contents: build_vmg(&[(1, 1, 1)]), + }, + FileSpec { + name: "VTS_01_0.IFO".into(), + icb_lba: 62, + data_lba: 6000, + contents: build_vts(0, 0x00, &[], &[], &[(0, 9)], false), + }, + ], + ) + } + + // A Stop raised before the scan, or during its last reads, is `Halted`, never a list. + #[test] + fn scan_dvd_titles_honours_the_halt_token() { + struct CancellingReader<'a> { + inner: &'a mut MemDisc, + halt: crate::halt::Halt, + reads: u32, + } + impl SectorSource for CancellingReader<'_> { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + self.reads += 1; + if lba >= PART_START + 6000 { + self.halt.cancel(); + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + let mut disc = MemDisc::new(); + let udf = one_title_dvd(&mut disc); + let halt = crate::halt::Halt::new(); + halt.cancel(); + let mut reader = CancellingReader { + inner: &mut disc, + halt: halt.clone(), + reads: 0, + }; + let res = Disc::scan_dvd_titles(&mut reader, &udf, Some(&halt)); + assert!(matches!(res, Err(crate::error::Error::Halted)), "{res:?}"); + assert_eq!(reader.reads, 0, "a cancelled scan reads nothing"); + + let halt = crate::halt::Halt::new(); + let mut reader = CancellingReader { + inner: &mut disc, + halt: halt.clone(), + reads: 0, + }; + let res = Disc::scan_dvd_titles(&mut reader, &udf, Some(&halt)); + assert!(matches!(res, Err(crate::error::Error::Halted)), "{res:?}"); + } + /// chapter_times from the IFO become Chapter entries with ordinal /// names (dvd.rs maps chapter_times → Chapter{time_secs, chapter_name}). #[test] diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index e0ef1977..baaff38f 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -354,5 +354,6 @@ mod tests { panic!("audio") }; assert_eq!(a.pid, 0xBD81); + assert_eq!(a.channels, AudioChannels::Surround51); } } diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index a5b788ff..5d3f4774 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -361,6 +361,7 @@ const MAX_XPL_TITLES: usize = 512; // disc-supplied XML. const MAX_XPL_BYTES: usize = 1024 * 1024; +// Max `.evo` clips resolved per scan (each costs an ICB read and a probe). const MAX_HDDVD_CLIPS: usize = 512; const MAX_XPL_CLIPS_PER_TITLE: usize = 256; @@ -2606,6 +2607,134 @@ mod tests { ); } + // A Stop is never a probe failure: it surfaces, whether the drive reports it or the + // token is already raised. + #[test] + fn probe_evo_streams_surfaces_a_stop() { + let ext = Extent { + start_lba: 777, + sector_count: 4, + }; + let mut halted = FailReader(|| crate::error::Error::Halted); + assert!(matches!( + probe_evo_streams(&mut halted, std::slice::from_ref(&ext), None), + Err(crate::error::Error::Halted) + )); + let halt = crate::halt::Halt::new(); + halt.cancel(); + let mut counter = ProbeCounter::new(MemDisc::new(), vec![777]); + assert!(matches!( + probe_evo_streams(&mut counter, std::slice::from_ref(&ext), Some(&halt)), + Err(crate::error::Error::Halted) + )); + assert_eq!(counter.sectors_read, 0, "a raised token reads nothing"); + } + + // A cancelled probe is not memoised as "no streams": the next title sharing the + // extents probes again. + #[test] + fn probe_cache_does_not_memoise_a_stop() { + let ext = [Extent { + start_lba: 777, + sector_count: 4, + }]; + let mut cache = EvoProbeCache::default(); + let mut halted = FailReader(|| crate::error::Error::Halted); + assert!(matches!( + cache.streams(&mut halted, &ext, None), + Err(crate::error::Error::Halted) + )); + let mut counter = ProbeCounter::new(MemDisc::new(), vec![777]); + cache.streams(&mut counter, &ext, None).expect("probe"); + assert_eq!(counter.hits[0], 1, "the extents are probed again"); + } + + // A Stop while resolving a clip's extents ends the scan; it is not "clip unreadable". + #[test] + fn scan_hddvd_titles_surfaces_a_stop_resolving_extents() { + struct HaltAt(MemDisc, u32); + impl SectorSource for HaltAt { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + if lba == self.1 { + return Err(crate::error::Error::Halted); + } + self.0.read_sectors(lba, count, buf, recovery) + } + } + let mut disc = MemDisc::new(); + let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo()); + let mut reader = HaltAt(disc, PART_START + 100); // FEATURE.EVO's ICB + let res = Disc::scan_hddvd_titles(&mut reader, &udf, None); + assert!(matches!(res, Err(crate::error::Error::Halted)), "{res:?}"); + + let halt = crate::halt::Halt::new(); + halt.cancel(); + let mut counter = ProbeCounter::new(reader.0, vec![]); + let res = Disc::scan_hddvd_titles(&mut counter, &udf, Some(&halt)); + assert!(matches!(res, Err(crate::error::Error::Halted)), "{res:?}"); + assert_eq!(counter.sectors_read, 0, "a raised token reads nothing"); + } + + // A title whose end precedes its begin never reports a negative duration. + #[test] + fn compose_xpl_titles_clamps_a_reversed_clip_duration_and_names_by_number() { + let clip_extents: BTreeMap)> = [( + "a.evo".to_string(), + ( + "A.EVO".to_string(), + 1000u64, + vec![Extent { + start_lba: 1, + sector_count: 1, + }], + ), + )] + .into_iter() + .collect(); + let xpl_titles = vec![XplTitle { + number: 7, + name: String::new(), + duration_secs: 10.0, + clips: vec![XplClip { + evo: "a.evo".to_string(), + begin_secs: 9.0, + end_secs: 5.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[0].clips[0].duration_secs, 0.0); + assert_eq!( + titles[0].playlist, "TITLE_7", + "an unnamed title is named by number" + ); + } + + // A title with no displayName is named by its id attribute. + #[test] + fn parse_xpl_titles_falls_back_to_the_id_attribute_for_the_name() { + let xpl = r#" + + <PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:10:00" src="file:///x/B.MAP"/> + "#; + let titles = parse_xpl_titles(xpl.as_bytes()); + assert_eq!(titles[0].name, "Bonus3"); + } + // ── read_adv_obj_xpl: prefix AND suffix are both required ────────────── /// A file matching the `vplst` prefix but NOT the `.xpl` suffix must not diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 58503058..a47e5019 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -2279,6 +2279,44 @@ mod tests { ); } + // A window that runs past the top of the 32-bit LBA space is skipped, never wrapped + // onto an unrelated low region. + #[test] + fn a_window_past_the_top_of_the_lba_space_is_not_wrapped() { + struct LbaLog(Vec); + impl SectorSource for LbaLog { + fn read_sectors( + &mut self, + lba: u32, + _count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + self.0.push(lba); + buf.fill(0); + Ok(buf.len()) + } + } + let start = u32::MAX - 10; + let mut title = pgs_title(0x1200, false); + title.extents = vec![Extent { + start_lba: start, + sector_count: 2_000_000, + }]; + let mut reader = LbaLog(Vec::new()); + probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); + assert!( + reader.0.iter().all(|&l| l >= start), + "read below the extent: {:?}", + reader + .0 + .iter() + .filter(|&&l| l < start) + .take(3) + .collect::>() + ); + } + // ...but a COMPLETE read has no unread gap to hide a non-forced set in, so a genuine // single-sign forced track is still promoted. #[test] diff --git a/src/disc/profile.rs b/src/disc/profile.rs index 093a2ae8..29242d29 100644 --- a/src/disc/profile.rs +++ b/src/disc/profile.rs @@ -496,6 +496,26 @@ mod tests { assert_eq!(dvd.video[0].codec, "mpeg2"); } + // Forced is the probe/STN flag OR the label qualifier: either alone is enough. + #[test] + fn subtitle_forced_is_the_flag_or_the_qualifier() { + for (flag, qualifier, want) in [ + (false, LabelQualifier::None, false), + (true, LabelQualifier::None, true), + (false, LabelQualifier::Forced, true), + (true, LabelQualifier::Forced, true), + ] { + let Stream::Subtitle(s) = subtitle("eng", flag, qualifier) else { + unreachable!() + }; + assert_eq!( + SubtitleTrack::from_stream(&s).forced, + want, + "{flag} {qualifier:?}" + ); + } + } + #[test] fn from_disc_hoists_main_and_is_main() { let mut disc = test_disc(); diff --git a/src/disc/scan_order_tests.rs b/src/disc/scan_order_tests.rs index d55d47c4..1d8de8f6 100644 --- a/src/disc/scan_order_tests.rs +++ b/src/disc/scan_order_tests.rs @@ -409,6 +409,40 @@ fn live_unreadable_uk_ro_is_recorded_scan_continues() { } } +#[test] +fn live_unreadable_mkb_is_not_fatal() { + let mut rig = Rig::new(bd_disc(Some(false)), |t| { + t.fail_read = Some(Box::new(|lba, n, _| { + (lba..lba + n as u32) + .contains(&MKB_LBA) + .then(|| check(3, 0x11, 0, 0x28)) + })); + }); + let d = + Disc::scan(&mut rig.drive, &with_hc()).expect("a medium error in the MKB: scan goes on"); + assert!(!d.titles.is_empty()); +} + +#[test] +fn live_unreadable_cert_is_logged_and_not_fatal() { + let mut rig = Rig::new(bd_disc(Some(false)), |t| { + t.fail_read = Some(Box::new(|lba, n, _| { + (lba..lba + n as u32) + .contains(&CERT_LBA) + .then(|| check(3, 0x11, 0, 0x28)) + })); + }); + let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc())); + let d = r.expect("a medium error in the cert: scan goes on"); + assert!(!d.titles.is_empty()); + assert!( + aacs_warns(&ev) + .iter() + .any(|e| e.field("phase") == Some("aacs_capture")), + "the cert read failure must be visible: {ev:?}" + ); +} + #[test] fn failed_handshake_cannot_poison_uk_ro_read() { let mut rig = Rig::new(bd_disc(Some(false)), |t| { From 535d1b8dd973c5daf42ea3fcedf7bb15af9fdff2 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:02:03 -0700 Subject: [PATCH 4/4] disc: warn on unreadable cert and MKB prefix, cap null mapfile name, release MKB padding capacity, fix stale docs; table tests --- src/disc/encrypt.rs | 11 +- src/disc/mod.rs | 239 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 229 insertions(+), 21 deletions(-) diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 1e897d21..10e55e60 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -193,7 +193,16 @@ pub(super) fn capture( ) { Ok(raw) => Some(raw), Err(Error::Halted) => return Err(Error::Halted), - Err(_) => None, + Err(Error::AacsNoKeys) => None, + Err(e) => { + tracing::warn!( + target: "freemkv::scan", + phase = "aacs_capture", + error_code = e.code(), + "content certificate unreadable; assuming the V20/UHD Unit_Key_RO stride" + ); + None + } }; let cc = cc_raw.as_deref().and_then(aacs::inf::parse_content_cert); // No-cert default = UHD (V20 stride), matching `read_aacs_version`. diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8db6c95b..8ee17142 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -49,9 +49,10 @@ pub struct Disc { pub capacity_bytes: u64, /// Number of layers (1 = single, 2 = dual) pub layers: u8, - /// Titles sorted by duration (longest first), then playlist name + /// Titles in main-feature order: `titles[0]` is the selected main feature (nav or + /// authoring pick first, else largest physical size), not necessarily the longest pub titles: Vec, - /// Disc region + /// Disc region. Not decoded yet: scans always report [`DiscRegion::Free`]. pub region: DiscRegion, /// AACS state -- None if disc is unencrypted or keys unavailable pub aacs: Option, @@ -713,8 +714,8 @@ fn bus_map(files: StreamFiles, titles: &[DiscTitle]) -> crate::sector::bus_remov crate::sector::bus_removal::BusMap::new(files, &unknown) } -// Corrects a title's TrueHD channels/sample-rate/Atmos by probing the first decrypted major -// sync (`reader` must yield DECRYPTED sectors: mux time, not scan). +// Test-only: corrects a title's TrueHD channels/sample-rate/Atmos by probing the first decrypted +// major sync. The production path is `correct_truehd_stream`, driven from mux/driver.rs. #[cfg(test)] pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) { let pids: Vec = title @@ -2277,7 +2278,10 @@ impl Disc { // whole content is captured; otherwise records may run past the // prefix — grow and retry, bounded by MAX_BYTES. if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES { - return Ok(crate::aacs::mkb::trim_mkb(buf)); + // The prefix buffer is sized for the padded file; release the unused capacity. + let mut mkb = crate::aacs::mkb::trim_mkb(buf); + mkb.shrink_to_fit(); + return Ok(mkb); } want = (want * 2).min(MAX_BYTES); } @@ -2532,15 +2536,14 @@ impl Disc { crate::labels::fill_defaults(&mut titles); // 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD) and - // layers. Region coding is not decoded yet — every disc reports - // Region-free (correct for UHD; a stub until BD/DVD region detection). + // layers. Region coding is not decoded yet: every disc reports Region-free. let format = Self::detect_disc_format(reader, &udf_fs, &titles); let layers = Self::layers_for(format, capacity); let region = DiscRegion::Free; - // 6. CSS detection for DVDs is deferred to `Disc::scan`'s drive-auth path - // (has `&mut Drive`), run after this returns — the reader-based crack path - // is NOT run here: on a CSS disc it would fail ~50,000 sectors one-by-one. + // 6. CSS: `scan_image` cracks the title key after this returns and `scan_live` only + // runs bus-auth. The reader-based crack is NOT run here: on a CSS disc it would + // fail ~50,000 sectors one-by-one. let css = None; tracing::info!( @@ -2833,15 +2836,20 @@ impl Disc { if udf_fs.find_dir("/BDMV").is_some() { // Only the Type-and-Version record (first record) is needed. let mkb_paths = crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb); - if let Ok(mkb) = - crate::aacs::read_first(&mkb_paths, |p| udf_fs.read_file_prefix(reader, p, 64)) - { - match mkb_type(&mkb).map(|t| t.generation()) { + match crate::aacs::read_first(&mkb_paths, |p| udf_fs.read_file_prefix(reader, p, 64)) { + Ok(mkb) => match mkb_type(&mkb).map(|t| t.generation()) { Some(AacsVersion::V21) => return DiscFormat::Fmts, Some(AacsVersion::V20) => return DiscFormat::Uhd, Some(AacsVersion::V10) => return DiscFormat::BluRay, None => {} - } + }, + // No MKB at all is an unencrypted disc, not a fault. + Err(Error::AacsNoKeys) => {} + Err(e) => tracing::warn!( + target: "freemkv::scan", + error_code = e.code(), + "MKB prefix unreadable; classifying the disc by resolution" + ), } // Unencrypted/unreadable MKB: refine by resolution, but a BD-tree disc // is never below Blu-ray — `detect_format` can return Dvd for an SD @@ -3190,13 +3198,17 @@ pub(crate) fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf std::path::PathBuf::from(s) } +// Disc-authored names can be arbitrarily long; the sanitised name stays well under NAME_MAX. +const NULL_MAPFILE_NAME_MAX_CHARS: usize = 128; + impl Disc { /// Path to the mapfile for a given output path. /// /// For the null device ([`crate::io::null_device`]), returns `{dir}/{volume_id_or_title}.mapfile` /// where `{dir}` is a directory this process created under the temp dir /// (owner-only on Unix, unpredictable name, stable for the process). For - /// regular files, returns `{path}.mapfile`. + /// regular files, returns `{path}.mapfile`. The null-device directory and the mapfile in it + /// outlive the process; removing them is the caller's job. pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf { if crate::io::is_null_device(path) { let name: String = self @@ -3204,6 +3216,7 @@ impl Disc { .as_deref() .unwrap_or(&self.volume_id) .chars() + .take(NULL_MAPFILE_NAME_MAX_CHARS) .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c @@ -3342,10 +3355,6 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 { } } -// ─── Format helpers ──────────────────────────────────────────────────────── - -// Old format_* functions replaced by Resolution/FrameRate/AudioChannels/SampleRate enums - #[cfg(test)] mod tests { use super::*; @@ -8705,6 +8714,196 @@ mod tests { assert_eq!(AudioChannels::from_audio_format(12), AudioChannels::Unknown); } + // The BD-ROM STN attribute nibbles decode through these tables into the fields + // that feed muxer timing and metadata. + #[test] + fn mpls_attribute_nibbles_decode_to_the_spec_values() { + use Resolution::*; + for (vf, want) in [ + (1, R480i), + (2, R576i), + (3, R480p), + (4, R1080i), + (5, R720p), + (6, R1080p), + (7, R576p), + (8, R2160p), + (0, Unknown), + ] { + assert_eq!(Resolution::from_video_format(vf), want, "video_format {vf}"); + } + for (vr, want, frac) in [ + (1, FrameRate::F23_976, (24000, 1001)), + (2, FrameRate::F24, (24, 1)), + (3, FrameRate::F25, (25, 1)), + (4, FrameRate::F29_97, (30000, 1001)), + (5, FrameRate::F30, (30, 1)), + (6, FrameRate::F50, (50, 1)), + (7, FrameRate::F59_94, (60000, 1001)), + (8, FrameRate::F60, (60, 1)), + ] { + assert_eq!(FrameRate::from_video_rate(vr), want, "video_rate {vr}"); + assert_eq!(want.as_fraction(), frac, "{want:?}"); + } + assert_eq!(FrameRate::from_video_rate(0), FrameRate::Unknown); + for (af, want) in [ + (1, AudioChannels::Mono), + (3, AudioChannels::Stereo), + (6, AudioChannels::Surround51), + ] { + assert_eq!( + AudioChannels::from_audio_format(af), + want, + "audio_format {af}" + ); + } + for (ar, want) in [ + (1, SampleRate::S48), + (4, SampleRate::S96), + (5, SampleRate::S192), + (12, SampleRate::S48_192), + (14, SampleRate::S48_96), + (0, SampleRate::Unknown), + ] { + assert_eq!(SampleRate::from_audio_rate(ar), want, "audio_rate {ar}"); + } + } + + #[test] + fn coding_type_maps_every_known_stream_to_its_codec() { + use crate::consts::coding_type as c; + for (ct, want) in [ + (c::HEVC, Codec::Hevc), + (c::H264, Codec::H264), + (c::H264_MVC, Codec::H264), + (c::VC1, Codec::Vc1), + (c::MPEG2_VIDEO, Codec::Mpeg2), + (c::TRUEHD, Codec::TrueHd), + (c::DTS_HD_MA, Codec::DtsHdMa), + (c::DTS_HD_HR, Codec::DtsHdHr), + (c::DTS, Codec::Dts), + (c::AC3, Codec::Ac3), + (c::AC3_PLUS, Codec::Ac3Plus), + (c::AC3_PLUS_SECONDARY, Codec::Ac3Plus), + (c::LPCM, Codec::Lpcm), + (c::DTS_HD_SECONDARY, Codec::DtsHdHr), + (c::PG, Codec::Pgs), + ] { + assert_eq!(Codec::from_coding_type(ct), want, "coding_type {ct:#04x}"); + } + } + + #[test] + fn every_hd_resolution_is_hd_and_detects_as_bluray() { + for r in [ + Resolution::R720p, + Resolution::R1080i, + Resolution::R1080p, + Resolution::R2160p, + ] { + assert!(r.is_hd(), "{r:?}"); + } + for r in [ + Resolution::R480i, + Resolution::R480p, + Resolution::R576i, + Resolution::R576p, + Resolution::Unknown, + ] { + assert!(!r.is_hd(), "{r:?}"); + } + for r in [Resolution::R720p, Resolution::R1080i] { + let titles = vec![title_with_video(Codec::H264, r)]; + assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay, "{r:?}"); + } + } + + // Key sampling skips a size-inflated streamless decoy, and falls back to the + // largest title when none has video. + #[test] + fn main_title_prefers_the_largest_title_with_video() { + let mut disc = make_test_disc(1_000, "BDROM"); + let mut decoy = DiscTitle::empty(); + decoy.playlist = "decoy".into(); + decoy.size_bytes = 9_000_000_000; + let mut feature = title_with_video(Codec::H264, Resolution::R1080p); + feature.playlist = "feature".into(); + feature.size_bytes = 1_000; + disc.titles = vec![decoy.clone(), feature]; + assert_eq!( + disc.main_title().map(|t| t.playlist.as_str()), + Some("feature") + ); + let mut small = decoy.clone(); + small.playlist = "small".into(); + small.size_bytes = 5; + disc.titles = vec![small, decoy]; + assert_eq!( + disc.main_title().map(|t| t.playlist.as_str()), + Some("decoy") + ); + } + + // The key-service label is the trimmed volume id, else the BDMV title; the AACS + // inputs come from the captured state. + #[test] + fn inputs_label_is_the_trimmed_volume_id_else_the_meta_title() { + let mut disc = make_test_disc(1_000, " TITLE_2024 "); + let mut st = aacs_empty(); + st.uk_ro = vec![1, 2, 3]; + st.mkb = vec![4, 5]; + disc.aacs = Some(st); + let i = disc.inputs().expect("inputs"); + assert_eq!(i.volume_label.as_deref(), Some("TITLE_2024")); + assert_eq!(i.unit_key_ro, vec![1, 2, 3]); + assert_eq!(i.mkb, vec![4, 5]); + disc.volume_id = " ".into(); + disc.meta_title = Some("Meta".into()); + assert_eq!( + disc.inputs().expect("inputs").volume_label.as_deref(), + Some("Meta") + ); + } + + #[test] + fn mapfile_for_dev_null_caps_a_long_disc_name() { + let disc = make_test_disc(1_000, &"x".repeat(1_000)); + let p = disc.mapfile_for(std::path::Path::new("/dev/null")); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + assert!(name.len() < 255, "{} bytes", name.len()); + } + + // A padded MKB is trimmed to its record stream, and the buffer does not keep the + // capacity of the padded prefix it was read into. + #[test] + fn read_mkb_content_releases_the_padding_capacity() { + use crate::udf::fixture::*; + let mut mkb = vec![0x04, 0x00, 0x10, 0x00]; + mkb.resize(0x1000, 0xAA); + mkb.extend_from_slice(&[0, 0, 0, 0]); + mkb.resize(17 * 1024 * 1024, 0); + 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: "AACS".into(), + icb_lba: 12, + dir_data_lba: 13, + files: vec![file_with("MKB_RO.inf", 14, 1000, mkb, true)], + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + let got = Disc::read_mkb_content(&mut disc, &udf).expect("mkb"); + assert_eq!(got.len(), 0x1000); + assert!(got.capacity() < 1024 * 1024, "capacity {}", got.capacity()); + } + #[test] fn layers_follow_the_format_capacity() { for (fmt, sectors, want) in [