From 06c30aa466610774afc60767fe6a7fd944277692 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:53:40 -0700 Subject: [PATCH] udf: honor ICB allocation-descriptor type (Short/Long/Extended AD) read_icb_extents hardcoded an 8-byte Short-AD stride for every file. Large BD-ROM .m2ts streams use 16-byte Long ADs; striding them as Short ADs reads descriptor #0 correctly (length+lba align) but lands #1 in the middle of the first Long AD (its zero impl_use bytes). The AD-list terminator (data_len==0 => break) then fired on that zero and stopped after the first extent, so every multi-extent title truncated at ~1 GiB. The same reader backs read_file, so disc AACS-input files (/AACS/*.inf) and the m2ts mux extents were both affected. Read the ICB Tag flags (AD type) and stride 8/16/20 bytes for Short/Long/ Extended ADs accordingly; Extended ADs carry the lba at off+12. aacs: extract trim_mkb and restore its guard so an MKB whose content length the parser cannot determine (mkb_content_len == 0) is returned intact instead of truncated to empty. Regression tests: Long-AD read_icb_extents returns all extents; Long-AD read_file returns full content; trim_mkb never zeroes an unrecognised MKB. --- src/aacs/keys.rs | 53 +++++++++++++++++ src/aacs/mod.rs | 1 + src/disc/mod.rs | 10 ++-- src/udf.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 198 insertions(+), 11 deletions(-) diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index ddb8b89..1c65e48 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -563,6 +563,21 @@ pub fn mkb_content_len(mkb: &[u8]) -> usize { pos } +/// Trim an MKB's trailing fixed-region padding to its real content length — +/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an +/// MKB whose first record cannot be parsed; truncating to 0 in that case would +/// hand downstream consumers (and the online key service) an EMPTY MKB that can +/// never resolve. So a 0 (or a length that isn't strictly inside the buffer) +/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and +/// `truncate`-d unconditionally, zeroing unrecognised MKBs. +pub fn trim_mkb(mut mkb: Vec) -> Vec { + let n = mkb_content_len(&mkb); + if n > 0 && n < mkb.len() { + mkb.truncate(n); + } + mkb +} + /// Get MKB version from Type and Version Record (type 0x10). /// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`). pub fn mkb_version(mkb: &[u8]) -> Option { @@ -1519,6 +1534,44 @@ mod tests { assert_eq!(mkb_content_len(&[]), 0); } + #[test] + fn trim_mkb_never_zeroes_an_unrecognised_mkb() { + // Regression: the 0.31.0 read_aacs_inputs path truncated the MKB to + // mkb_content_len() unconditionally. For an MKB whose first record the + // parser can't read, mkb_content_len() returns 0 → an unconditional + // truncate zeroed the MKB, so autorip sent an EMPTY MKB to the key + // service (or skipped the request). trim_mkb must leave it intact. + let unrecognised = vec![0xFFu8; 4096]; // first "rec_type" 0xFF, rec_len huge → content_len 0 + assert_eq!( + mkb_content_len(&unrecognised), + 0, + "precondition: unparseable → 0" + ); + assert_eq!( + trim_mkb(unrecognised.clone()), + unrecognised, + "unrecognised MKB must be returned untouched, never zeroed" + ); + + // A parseable MKB with trailing padding IS trimmed to its records. + let mut mkb = vec![ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D, + ]; + mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]); + mkb.extend_from_slice(&[0xAB; 16]); + mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + let records_len = mkb.len(); + mkb.extend(std::iter::repeat(0u8).take(1024)); + assert_eq!( + trim_mkb(mkb).len(), + records_len, + "padded MKB trims to records" + ); + + // Empty stays empty (n==0 → untouched). + assert!(trim_mkb(Vec::new()).is_empty()); + } + #[test] fn mkb_version_returns_none_on_empty() { assert_eq!(mkb_version(&[]), None); diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 6d61c3f..a36ae79 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -34,6 +34,7 @@ pub use keys::{ derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked, derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21, + trim_mkb, }; pub use provider::KeyProvider; pub use variants::{ diff --git a/src/disc/mod.rs b/src/disc/mod.rs index db84dc3..aaab6f2 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1307,13 +1307,15 @@ impl Disc { .read_file(reader, "/AACS/Unit_Key_RO.inf") .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) .map_err(|_| Error::AacsNoKeys)?; - let mut mkb = udf_fs + let mkb = udf_fs .read_file(reader, "/AACS/MKB_RO.inf") .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf")) .map_err(|_| Error::AacsNoKeys)?; - let n = crate::aacs::mkb_content_len(&mkb); - mkb.truncate(n); - Ok((inf, mkb)) + // Trim trailing padding to the real MKB content length, never zeroing + // an unrecognised MKB (see `crate::aacs::trim_mkb` — restores the + // pre-0.31.0 guard so the online key service never receives an empty + // MKB). + Ok((inf, crate::aacs::trim_mkb(mkb))) } /// Read a disc's AACS key-input files from an ISO image: returns diff --git a/src/udf.rs b/src/udf.rs index ca9e4b1..638029d 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -437,6 +437,33 @@ impl UdfFs { } }; + // Allocation-descriptor type lives in the ICB Tag flags (low 3 + // bits). The ICB Tag immediately follows the 16-byte descriptor + // tag, and its `flags` u16 is the last field at ICB-tag offset 18 + // → absolute offset 34, for both File Entry (261) and Extended + // File Entry (266). 0 = Short AD (8 bytes), 1 = Long AD (16 bytes), + // 2 = Extended AD (20 bytes), 3 = data embedded inline in the ICB. + // + // This MUST be honoured: a Short AD and a Long AD both carry + // length+lba in their first 8 bytes, so hardcoding an 8-byte + // stride reads descriptor #0 of a Long-AD file correctly but lands + // descriptor #1 in the middle of the first Long AD (its impl_use + // bytes) — garbage that trips the terminator/unknown-type break. + // Large BD-ROM .m2ts streams use Long ADs, so that bug truncated + // every multi-extent title at its first extent (~973 MB-1 GiB). + let icb_flags = u16::from_le_bytes([icb[34], icb[35]]); + let ad_type = (icb_flags & 0x07) as usize; + let ad_size: usize = match ad_type { + 0 => 8, // Short AD + 1 => 16, // Long AD + 2 => 20, // Extended AD + // 3 = inline/embedded data (no out-of-line extents) — never + // used for large stream files. Anything else is unexpected; + // fall back to the historical 8-byte stride rather than fail + // the whole title. + _ => 8, + }; + let mut extents = Vec::new(); // Parse the first allocation-descriptor list from the ICB. A type-3 @@ -450,12 +477,12 @@ impl UdfFs { const MAX_AD_BLOCKS: usize = 256; for _ in 0..MAX_AD_BLOCKS { - let num_descriptors = ad_bytes / 8; // Short Allocation Descriptor = 8 bytes + let num_descriptors = ad_bytes / ad_size; let mut next_block: Option = None; for i in 0..num_descriptors { - let off = ad_start + i * 8; - if off + 8 > block.len() { + let off = ad_start + i * ad_size; + if off + ad_size > block.len() { break; } @@ -467,11 +494,15 @@ impl UdfFs { ]); let extent_type = raw_len >> 30; let data_len = raw_len & 0x3FFF_FFFF; + // Short and Long ADs carry the extent LBA at off+4. Extended + // ADs (20 bytes) place their extent_location lb_addr after + // three length fields, at off+12. + let lba_off = if ad_size == 20 { off + 12 } else { off + 4 }; let data_lba = u32::from_le_bytes([ - block[off + 4], - block[off + 5], - block[off + 6], - block[off + 7], + block[lba_off], + block[lba_off + 1], + block[lba_off + 2], + block[lba_off + 3], ]); match extent_type { @@ -1278,6 +1309,35 @@ mod tests { s } + /// Build an Extended File Entry (tag 266) ICB whose allocation + /// descriptors are LONG ADs (16 bytes: len(4) | lba(4) | part_ref(2) | + /// impl_use(6)). Sets the ICB Tag flags (abs offset 34) low bits to 1 + /// so the parser must select the 16-byte stride. This is the layout + /// large BD-ROM .m2ts streams actually use. + fn build_efe_long(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] { + let mut s = [0u8; 2048]; + s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag + // ICB Tag flags at abs offset 34: AD type 1 = Long AD. + s[34..36].copy_from_slice(&1u16.to_le_bytes()); + s[56..64].copy_from_slice(&info_length.to_le_bytes()); + let l_ea: u32 = 0; + let l_ad: u32 = (ads.len() * 16) as u32; + s[208..212].copy_from_slice(&l_ea.to_le_bytes()); + s[212..216].copy_from_slice(&l_ad.to_le_bytes()); + let mut off = 216 + l_ea as usize; + for &(etype, dlen, dlba) in ads { + let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF); + s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes()); + s[off + 4..off + 8].copy_from_slice(&dlba.to_le_bytes()); + // off+8..off+10 = partition reference (0), off+10..off+16 = + // impl_use (0). Leaving these zero is what trips the old + // 8-byte-stride parser into reading a bogus zero-length + // terminator as descriptor #1. + off += 16; + } + s + } + /// A continuation block: a bare list of short ADs from byte 0. fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] { let mut s = [0u8; 2048]; @@ -1335,6 +1395,42 @@ mod tests { assert_eq!(extents, vec![(10, 4096), (20, 2048)]); } + #[test] + fn icb_extents_long_ad_returns_all_extents_not_just_first() { + // Regression: BD-ROM large .m2ts files use Long ADs (16-byte + // descriptors). The pre-fix parser hardcoded an 8-byte stride, so + // it read descriptor #0 (length+lba align in both layouts) then + // misread descriptor #1 from the middle of the first Long AD — + // a zero terminator — and returned ONLY the first extent. That + // truncated every multi-extent title at ~973 MB-1 GiB. + // + // Four Long ADs, each a near-max Short-AD-sized extent. The fix + // must return all four; the old code returned exactly one. + let icb = build_efe_long( + 4 * 1_000_000_000, + &[ + (0, 0x3FFF_F800, 100), // ~1 GiB extent + (0, 0x3FFF_F800, 600_000), // next extent + (0, 0x3FFF_F800, 1_100_000), + (0, 0x1000_0000, 1_600_000), // shorter tail extent + ], + ); + let mut reader = MapReader::new(); + reader.put(5, icb); + let fs = fs_with(0, 0, file_entry("BIG", 5, 4 * 1_000_000_000)); + let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + assert_eq!( + extents, + vec![ + (100, 0x3FFF_F800), + (600_000, 0x3FFF_F800), + (1_100_000, 0x3FFF_F800), + (1_600_000, 0x1000_0000), + ], + "Long-AD file must return ALL extents, not just the first" + ); + } + #[test] fn read_file_spans_multiple_extents() { let part_start = 0; @@ -1360,6 +1456,41 @@ mod tests { assert!(data[2048..].iter().all(|&b| b == 0xBB)); } + #[test] + fn read_file_long_ad_returns_full_content_not_truncated() { + // Regression for BOTH 0.31.0 bugs through `read_file`: this is the + // exact path `Disc::read_aacs_inputs_from_reader` uses to read + // `/AACS/MKB_RO.inf` + `Unit_Key_RO.inf`. A Long-AD, multi-extent + // file (Dunkirk-class UHD layout) must return ALL its bytes. With the + // pre-fix Short-AD-only parser this read stopped after the first + // extent, which (a) truncated the mux and (b) made autorip's + // `key_files()` see a short/garbage AACS file → `MissingInputs` → + // the online key request was never sent. + let icb = build_efe_long(6144, &[(0, 2048, 10), (0, 2048, 30), (0, 2048, 50)]); + let mut reader = MapReader::new(); + reader.put(5, icb); + reader.put(10, [0xAA; 2048]); + reader.put(30, [0xBB; 2048]); + reader.put(50, [0xCC; 2048]); + let root = DirEntry { + name: String::new(), + is_dir: true, + meta_lba: 0, + size: 0, + entries: vec![file_entry("MKB", 5, 6144)], + }; + let fs = fs_with(0, 0, root); + let data = fs.read_file(&mut reader, "/MKB").expect("read"); + assert_eq!( + data.len(), + 6144, + "Long-AD file must not truncate at extent #0" + ); + assert!(data[..2048].iter().all(|&b| b == 0xAA)); + assert!(data[2048..4096].iter().all(|&b| b == 0xBB)); + assert!(data[4096..].iter().all(|&b| b == 0xCC)); + } + #[test] fn merge_ranges_saturates_near_u32_max() { // Adjacent ranges near u32::MAX must not panic (debug) or wrap.