udf: read AACS MKB length-aware; honor inline allocation descriptors

read_file rejected the padded ~128 MiB MKB_RO.inf via the 0.31.0
MAX_FILE_BYTES cap, so read_aacs_inputs failed and the online
key-resolve path never contacted the keyserver. Read the MKB's real
record length from its header and read exactly that. Also honor
inline/embedded (AD type 3) files so small AACS .inf files read from
the ICB payload instead of being misparsed as allocation descriptors.

Release 0.31.6.
This commit is contained in:
Matthew Jackson
2026-06-08 19:20:41 -07:00
parent 41a6d89cd1
commit f80551f278
4 changed files with 203 additions and 53 deletions
+20
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## 0.31.6 (2026-06-09)
### Fixed
- UDF `read_file`: the AACS Media Key Block (`MKB_RO.inf`) is allocated to a
fixed ~128 MiB on UHD discs and zero-padded, but the real record stream is
only a few MiB. 0.31.0 added a 64 MiB `MAX_FILE_BYTES` cap on `read_file`,
which rejected the padded MKB outright — so `read_aacs_inputs` failed and the
online key-resolve path reported "could not read this disc's key files" and
**never contacted the keyserver** (a regression on every disc whose MKB
exceeds 64 MiB). The MKB is now read length-aware: a bounded prefix is read,
the real record length is found via the MKB header, and exactly that is
returned — never the padding, and never tripping the cap. Validated
end-to-end against a real UHD ISO (MKB reads and trims to its record length).
- UDF `read_file`: honor inline/embedded allocation descriptors (ICB Tag flags
low bits == 3). Tiny files (some AACS `*.inf` key files) store their data
embedded directly in the ICB with no out-of-line extents; these are now read
from the ICB payload instead of misparsing the embedded bytes as allocation
descriptors (which could hard-error since 0.31.0).
## 0.31.5 (2026-06-08) ## 0.31.5 (2026-06-08)
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.31.5" version = "0.31.6"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+34 -9
View File
@@ -1307,15 +1307,40 @@ impl Disc {
.read_file(reader, "/AACS/Unit_Key_RO.inf") .read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?; .map_err(|_| Error::AacsNoKeys)?;
let mkb = udf_fs let mkb = Self::read_mkb_content(reader, udf_fs)?;
.read_file(reader, "/AACS/MKB_RO.inf") Ok((inf, mkb))
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf")) }
.map_err(|_| Error::AacsNoKeys)?;
// Trim trailing padding to the real MKB content length, never zeroing /// Read the AACS MKB's real record stream — NOT its zero padding.
// 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_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
// MKB). /// zero-padded; the actual record stream is a few MiB. We read a bounded
Ok((inf, crate::aacs::trim_mkb(mkb))) /// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
/// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
/// report "could not read this disc's key files" without ever contacting
/// the keyserver.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, "/AACS/MKB_RO.inf", want)
.or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the 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::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
} }
/// Read a disc's AACS key-input files from an ISO image: returns /// Read a disc's AACS key-input files from an ISO image: returns
+148 -43
View File
@@ -21,11 +21,19 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::sector::SectorSource; use crate::sector::SectorSource;
/// Upper bound on a single metadata file read (`read_file`). BD-ROM /// Upper bound on a single UNBOUNDED metadata file read (`read_file`).
/// metadata files (.mpls/.clpi/.inf/.bdmv) are a few KiB to tens of MiB; /// BD-ROM metadata files (.mpls/.clpi/.bdmv/.inf) are a few KiB to a few
/// 64 MiB is a generous ceiling. Caps the allocation so a crafted ICB /// MiB; 64 MiB is a generous ceiling that bounds the allocation a crafted
/// info_length / extent length cannot force a huge zeroed reservation /// ICB info_length / extent length can force.
/// before any data is read. ///
/// The one legitimately huge file — the AACS `MKB_RO.inf`, allocated to a
/// fixed ~128 MiB and zero-padded — is NOT read through the unbounded path:
/// `read_aacs_inputs_from_reader` reads a bounded prefix via
/// `read_file_prefix` and trims to the real record length, so it never
/// trips this cap (and never reads 100+ MiB of padding). A 0.31.0
/// regression added this cap and read the MKB unbounded, so the cap
/// rejected it → `read_aacs_inputs` failed → autorip reported "could not
/// read this disc's key files" and never contacted the keyserver.
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
/// Upper bound on a single directory's on-disc data. Real BD-ROM /// Upper bound on a single directory's on-disc data. Real BD-ROM
@@ -135,6 +143,35 @@ impl UdfFs {
/// Read a file by path, returning its raw bytes. /// Read a file by path, returning its raw bytes.
/// Reads all data extents sector by sector from disc — no buffering. /// Reads all data extents sector by sector from disc — no buffering.
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> { pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, None)
}
/// Read at most `max_bytes` of a file (rounded up to a whole sector),
/// stopping early rather than reading the whole file.
///
/// Used to read only the real, record-length portion of the AACS
/// `MKB_RO.inf` — allocated to a fixed ~128 MiB and zero-padded — instead
/// of reading 100+ MiB of padding (and tripping `MAX_FILE_BYTES`). The
/// caller trims the returned prefix to the MKB record length.
pub fn read_file_prefix(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, Some(max_bytes))
}
/// Shared implementation of [`read_file`] / [`read_file_prefix`]. When
/// `max_bytes` is `Some`, reads at most that many bytes and the whole-file
/// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is
/// not applied (the read is already bounded by `max_bytes`).
fn read_file_limited(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: Option<usize>,
) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root; let mut current = &self.root;
@@ -166,16 +203,34 @@ impl UdfFs {
path: path.to_string(), path: path.to_string(),
})?; })?;
// Read ALL the file's data extents. Multi-extent files (fragmented or // `max_bytes == None` => read the whole file; `Some(n)` => read at most
// split across dual layers) would otherwise be silently truncated to // n bytes (rounded up to a sector) and skip the anti-DoS caps below.
// the first extent, since the buffer is sized to entry.size and let limit = max_bytes.unwrap_or(usize::MAX);
// truncate() can't grow it.
// Tiny files (notably the AACS `*.inf` key files) may store their data
// embedded inline in the ICB (AD type 3), with no out-of-line extents.
// Honor that before the extent path, which would otherwise misparse the
// embedded bytes as allocation descriptors and (since 0.31.0) hard-error
// on the resulting bogus extent length.
if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? {
let want = (entry.size as usize).min(limit);
if inline.len() > want {
inline.truncate(want);
}
return Ok(inline);
}
// Read the file's data extents. Multi-extent files (fragmented or split
// across dual layers) would otherwise be silently truncated to the
// first extent, since the buffer is sized to entry.size and truncate()
// can't grow it.
let extents = self.read_icb_extents(reader, entry.meta_lba)?; let extents = self.read_icb_extents(reader, entry.meta_lba)?;
// Reject an oversized declared total before allocating: entry.size is // Reject an oversized declared total before allocating: entry.size is a
// a raw u64 off the ICB, so a crafted file could otherwise force a // raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation across its extents. // multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a
if entry.size > MAX_FILE_BYTES { // bounded read is limited by `limit` regardless of the declared size.
if max_bytes.is_none() && entry.size > MAX_FILE_BYTES {
return Err(Error::DiscRead { return Err(Error::DiscRead {
sector: self.partition_start as u64, sector: self.partition_start as u64,
status: None, status: None,
@@ -183,33 +238,30 @@ impl UdfFs {
}); });
} }
// Read ALL the file's data extents. File DATA is in the physical // Read the file DATA from the physical partition (partition_start +
// partition (partition_start + lba), NOT the metadata partition: ICBs // lba), NOT the metadata partition: ICBs are in metadata, data is in
// are in metadata, data is in physical. // physical. Pre-allocate to the smaller of declared size and the
let mut data = Vec::with_capacity(entry.size as usize); // requested prefix (capped so a bogus entry.size can't reserve GiB).
let cap_hint = (entry.size as usize)
.min(limit)
.min(MAX_FILE_BYTES as usize);
let mut data = Vec::with_capacity(cap_hint);
let mut sector = [0u8; 2048]; let mut sector = [0u8; 2048];
for (data_lba, data_len) in extents { 'extents: for (data_lba, data_len) in extents {
// Cumulative guard: entry.size and each per-extent data_len are if max_bytes.is_none() {
// capped individually above, but a crafted ICB can chain many // Anti-DoS guards for the unbounded path: a crafted ICB can
// small extents (read_icb_extents follows type-3 chains up to // chain many extents whose running total grows `data` into GiB,
// MAX_AD_BLOCKS) whose running total grows `data` into GiB. Reject // or declare a single oversized extent. (Skipped when bounded —
// once the accumulated bytes would exceed MAX_FILE_BYTES. // `limit` already caps the read.)
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES { if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES
return Err(Error::DiscRead { || data_len as u64 > MAX_FILE_BYTES
sector: self.partition_start as u64, {
status: None, return Err(Error::DiscRead {
sense: None, sector: self.partition_start as u64,
}); status: None,
} sense: None,
// data_len is the disc-controlled 30-bit extent length; reject an });
// oversized extent before reading so a crafted ICB can't grow the }
// buffer past MAX_FILE_BYTES.
if data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
} }
let abs_start = self let abs_start = self
.partition_start .partition_start
@@ -221,6 +273,9 @@ impl UdfFs {
})?; })?;
let sector_count = (data_len as u64).div_ceil(2048) as u32; let sector_count = (data_len as u64).div_ceil(2048) as u32;
for i in 0..sector_count { for i in 0..sector_count {
if data.len() >= limit {
break 'extents;
}
let abs = abs_start.checked_add(i).ok_or(Error::DiscRead { let abs = abs_start.checked_add(i).ok_or(Error::DiscRead {
sector: abs_start as u64, sector: abs_start as u64,
status: None, status: None,
@@ -231,10 +286,12 @@ impl UdfFs {
} }
} }
// Trim to the real file size; if extents under-covered the file (e.g. // Trim to the real file size, or to the requested prefix — whichever is
// sparse), leave what we have rather than over-reporting. // smaller. If extents under-covered the file (e.g. sparse), leave what
if data.len() > entry.size as usize { // we have rather than over-reporting.
data.truncate(entry.size as usize); let trim_to = (entry.size as usize).min(limit);
if data.len() > trim_to {
data.truncate(trim_to);
} }
Ok(data) Ok(data)
} }
@@ -382,6 +439,54 @@ impl UdfFs {
}) })
} }
/// If this ICB stores its file data INLINE (embedded — ICB Tag flags low
/// 3 bits == 3) rather than via out-of-line extents, return the embedded
/// bytes. Tiny files such as the AACS `*.inf` key files are routinely
/// embedded directly in the ICB; `read_icb_extents` finds no real extents
/// for them (it would misparse the embedded payload as allocation
/// descriptors), so `read_file` must read the inline payload here. Returns
/// `Ok(None)` for the normal extent-backed case.
///
/// (Regression guard: 0.31.0 added a per-extent `MAX_FILE_BYTES` cap that
/// turned the misparsed-embedded case into a hard error, which surfaced as
/// autorip "could not read this disc's key files" on discs whose AACS
/// `.inf` files are ICB-embedded — the keyserver was then never called.)
fn read_inline_data(
&self,
reader: &mut dyn SectorSource,
meta_lba: u32,
) -> Result<Option<Vec<u8>>> {
let icb_abs = self.meta_to_abs(meta_lba)?;
let mut icb = [0u8; 2048];
read_sector(reader, icb_abs, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
let (ad_offset, l_ad) = match tag {
// Extended File Entry (266) / standard File Entry (261): the
// allocation-descriptors field (which, for embedded files, holds
// the data itself) begins after the extended attributes.
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
(216 + l_ea, l_ad)
}
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
(176 + l_ea, l_ad)
}
_ => return Ok(None),
};
// ICB Tag flags: u16 at absolute offset 34, low 3 bits select the AD
// type. 3 == data embedded inline in the ICB.
let icb_flags = u16::from_le_bytes([icb[34], icb[35]]);
if (icb_flags & 0x07) != 3 {
return Ok(None);
}
let start = ad_offset.min(icb.len());
let end = ad_offset.saturating_add(l_ad).min(icb.len());
Ok(Some(icb[start..end].to_vec()))
}
/// Read ALL allocation extents for a file from its ICB. /// Read ALL allocation extents for a file from its ICB.
/// Returns Vec of (partition_relative_lba, byte_length) pairs. /// Returns Vec of (partition_relative_lba, byte_length) pairs.
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents) /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)