mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Merge branch 'audit-lib-G2a' into audit-fix-lib
This commit is contained in:
+200
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
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("<di:name>") {
|
||||
let s = start + "<di:name>".len();
|
||||
if let Some(end) = xml[s..].find("</di:name>") {
|
||||
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<Extent>, feed_pos: &mut u64) {
|
||||
@@ -532,6 +542,8 @@ fn push_extents(file_exts: Vec<(u32, u32)>, extents: &mut Vec<Extent>, 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("<![CDATA[")
|
||||
@@ -1855,6 +1867,184 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The 3D `.ssif` interleaves the dependent view, so `size_bytes` follows the
|
||||
/// extents the read plan covers, not the base-view-only CLPI packet count.
|
||||
#[test]
|
||||
fn parse_playlist_3d_size_covers_the_interleaved_extents() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs_ssif(&mut disc, &[("00001", 1000, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(1, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[se_video(0x1011, 0x1B)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.size_bytes, 1000 * 2048);
|
||||
}
|
||||
|
||||
fn se_raw(pid: u16, attrs: &[u8]) -> Vec<u8> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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"<x><di:name>A[2JB\x07C</di:name></x>".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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+108
@@ -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<usize> {
|
||||
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]
|
||||
|
||||
@@ -354,5 +354,6 @@ mod tests {
|
||||
panic!("audio")
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD81);
|
||||
assert_eq!(a.channels, AudioChannels::Surround51);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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`.
|
||||
|
||||
+216
-6
@@ -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<crate::udf::AbsExtent>,
|
||||
/// 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 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)?
|
||||
};
|
||||
files.push(PlannedFile {
|
||||
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<u8>, u32);
|
||||
impl SectorSource for FailAt {
|
||||
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result<usize> {
|
||||
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<usize> {
|
||||
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() {
|
||||
|
||||
@@ -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<usize> {
|
||||
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<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();
|
||||
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#"<Playlist><TitleSet timeBase="60fps">
|
||||
<Title titleNumber="3" titleDuration="00:00:10:00" id="Bonus3">
|
||||
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:10:00" src="file:///x/B.MAP"/>
|
||||
</Title></TitleSet></Playlist>"#;
|
||||
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
|
||||
|
||||
+219
-20
@@ -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<DiscTitle>,
|
||||
/// 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<AacsState>,
|
||||
@@ -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<u16> = 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 [
|
||||
|
||||
@@ -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<u32>);
|
||||
impl SectorSource for LbaLog {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
_count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
// ...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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user