mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Adds a generic BD-J feature-playlist resolver that runs when no framework parser surfaced a manifest hint, plus a refinement to Sony/Fox feature selection. The hint now rides an independent pass so it survives even when the winning parser (or no parser) produced any labels. - labels/mod.rs: resolve_feature_hint() independent hint pass wired into apply(); register mod bdj_feature. - labels/paramount.rs (+ fox.rs mirror): refine feature selection — exact name="Feature" → /feature/i by longest duration → most-audio by duration; hard-reject sub-minute playlists (kills the _Start_Angle 2s decoy). Fixes Sony SM3 UHD. feature_hint_from_xml() exposed for Tier-1. - labels/bdj_feature.rs (NEW): Tier-1 jar-space resource sweep (dcx.xml/playlists.xml/*.properties) reusing fox/paramount hints; Tier-2 autostart-Xlet locator scan (BDJO AMT → autostart jar → NNNNN.mpls / PLAYLIST:N String constants → intersect real PLAYLIST set → longest-dominant multi-audio wins; integer-only never emits). - bdnav/bdjo.rs (NEW): minimal bounds-checked /BDMV/BDJO/*.bdjo Application Management Table reader (autostart initial_class + jar ids), panic-free like bdnav/index.rs. - labels/jar.rs: try_each_resource() helper for the Tier-1 sweep. No changes to disc/mod.rs selection or bdnav/vm.rs.
371 lines
15 KiB
Rust
371 lines
15 KiB
Rust
//! BD-J jar utilities — common scaffolding for parsers that read
|
|
//! `/BDMV/JAR/*.jar`.
|
|
//!
|
|
//! Composes with [`class_reader`](super::class_reader) for structured
|
|
//! `.class` access. Used by `dbp` (string-pool scan via constant pool)
|
|
//! and `deluxe` (bytecode pattern matching) — those parsers express
|
|
//! "open every top-level jar, look at every .class inside" without
|
|
//! repeating the zip-archive boilerplate.
|
|
|
|
use super::class_reader::ClassFile;
|
|
use crate::sector::SectorSource;
|
|
use crate::udf::UdfFs;
|
|
use std::io::{Cursor, Read};
|
|
use zip::ZipArchive;
|
|
|
|
// Cap on bytes read from one `.class` entry — the jar's declared size
|
|
// is attacker-controlled, so the buffer grows incrementally instead of
|
|
// pre-sizing. See docs/jar.md — MAX_CLASS_BYTES rationale.
|
|
const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
|
|
|
// Cap on bytes read from one non-`.class` resource entry (the dcx.xml /
|
|
// playlists.xml / *.properties manifests the Tier-1 sweep reads). Same
|
|
// attacker-controlled-size defence as MAX_CLASS_BYTES — see docs/jar.md.
|
|
const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
|
|
|
|
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
|
|
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
|
|
/// etc.
|
|
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
|
|
|
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
|
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
|
/// produces, or `None` if every jar was visited without a hit.
|
|
///
|
|
/// "Top-level" means entries directly under `/BDMV/JAR/`, not nested
|
|
/// under a subdir.
|
|
///
|
|
/// Entries that fail to read from UDF or that aren't valid zips are
|
|
/// silently skipped — same defensive shape as the existing dbp parser.
|
|
pub fn for_each_jar<R, F>(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) -> Option<R>
|
|
where
|
|
F: FnMut(&str, &mut Jar) -> Option<R>,
|
|
{
|
|
// See docs/jar.md — for_each_jar "top-level" vendor examples.
|
|
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
|
for entry in &jar_dir.entries {
|
|
if entry.is_dir {
|
|
continue;
|
|
}
|
|
if !entry.name.to_lowercase().ends_with(".jar") {
|
|
continue;
|
|
}
|
|
let path = format!("/BDMV/JAR/{}", entry.name);
|
|
let Ok(bytes) = udf.read_file(reader, &path) else {
|
|
continue;
|
|
};
|
|
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
|
|
continue;
|
|
};
|
|
if let Some(r) = f(&entry.name, &mut archive) {
|
|
return Some(r);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// True if any entry in this jar's central directory starts with
|
|
/// `prefix`. Fast — only reads filenames, never extracts bytes.
|
|
///
|
|
/// Used by parsers as a cheap "is this MY framework's jar?" check
|
|
/// (e.g. `has_path_prefix(archive, "com/dbp/")` for dbp,
|
|
/// `has_path_prefix(archive, "com/bydeluxe/")` for Deluxe).
|
|
pub fn has_path_prefix(archive: &Jar, prefix: &str) -> bool {
|
|
archive.file_names().any(|n| n.starts_with(prefix))
|
|
}
|
|
|
|
/// Iterate every `.class` entry in the jar, parse it with
|
|
/// [`class_reader`](super::class_reader), and call `f` with `(entry_name, &ClassFile)`.
|
|
///
|
|
/// Entries that fail to read or parse are silently skipped — this is
|
|
/// label-extraction code, robustness matters more than completeness.
|
|
/// Callers that need to know which classes failed should use the
|
|
/// lower-level [`class_reader`](super::class_reader) API directly.
|
|
pub fn for_each_class<F>(archive: &mut Jar, mut f: F)
|
|
where
|
|
F: FnMut(&str, &ClassFile),
|
|
{
|
|
// Defer to try_each_class; the callback always yields None so
|
|
// iteration never short-circuits.
|
|
try_each_class(archive, |name, class| {
|
|
f(name, class);
|
|
None::<()>
|
|
});
|
|
}
|
|
|
|
/// Like [`for_each_class`] but allows the callback to short-circuit
|
|
/// iteration. Returns the first `Some(R)` the callback produces.
|
|
pub fn try_each_class<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
|
|
where
|
|
F: FnMut(&str, &ClassFile) -> Option<R>,
|
|
{
|
|
for i in 0..archive.len() {
|
|
let Ok(entry) = archive.by_index(i) else {
|
|
continue;
|
|
};
|
|
if !entry.name().ends_with(".class") {
|
|
continue;
|
|
}
|
|
let name = entry.name().to_string();
|
|
// The declared uncompressed size is attacker-controlled, so the
|
|
// buffer grows incrementally and the read is capped at
|
|
// MAX_CLASS_BYTES rather than pre-sized from entry.size().
|
|
let mut bytes = Vec::new();
|
|
if entry.take(MAX_CLASS_BYTES).read_to_end(&mut bytes).is_err() {
|
|
continue;
|
|
}
|
|
let Ok(class) = ClassFile::parse(&bytes) else {
|
|
continue;
|
|
};
|
|
if let Some(r) = f(&name, &class) {
|
|
return Some(r);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Iterate every NON-`.class` entry in the jar, read its bytes (bounded by
|
|
/// [`MAX_RESOURCE_BYTES`]), and call `f` with `(entry_name, &bytes)`. Returns
|
|
/// the first `Some(R)` the callback produces, or `None` when every resource was
|
|
/// visited without a hit.
|
|
///
|
|
/// This is the Tier-1 menu-walk sweep: newer discs embed the same
|
|
/// `dcx.xml`/`playlists.xml` manifests INSIDE a jar rather than as loose
|
|
/// `/BDMV/JAR/<id>/` files. Directory entries and `.class` files are skipped;
|
|
/// unreadable entries are silently ignored (robustness over completeness).
|
|
pub fn try_each_resource<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
|
|
where
|
|
F: FnMut(&str, &[u8]) -> Option<R>,
|
|
{
|
|
for i in 0..archive.len() {
|
|
let Ok(entry) = archive.by_index(i) else {
|
|
continue;
|
|
};
|
|
let name = entry.name().to_string();
|
|
if name.ends_with('/') || name.to_ascii_lowercase().ends_with(".class") {
|
|
continue;
|
|
}
|
|
// Declared uncompressed size is attacker-controlled — grow the buffer
|
|
// incrementally and cap the read rather than pre-sizing from it.
|
|
let mut bytes = Vec::new();
|
|
if entry
|
|
.take(MAX_RESOURCE_BYTES)
|
|
.read_to_end(&mut bytes)
|
|
.is_err()
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(r) = f(&name, &bytes) {
|
|
return Some(r);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Smallest constant-pool-empty `.class`: magic, versions, cp_count=1
|
|
/// (zero real entries), then empty access/this/super/interfaces/
|
|
/// fields/methods/attributes.
|
|
const MINIMAL_CLASS: &[u8] = &[
|
|
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
|
0x00, 0x00, // minor
|
|
0x00, 0x00, // major
|
|
0x00, 0x01, // constant_pool_count = 1 -> no entries
|
|
0x00, 0x00, // access_flags
|
|
0x00, 0x00, // this_class
|
|
0x00, 0x00, // super_class
|
|
0x00, 0x00, // interfaces_count
|
|
0x00, 0x00, // fields_count
|
|
0x00, 0x00, // methods_count
|
|
0x00, 0x00, // attributes_count
|
|
];
|
|
|
|
// Build a raw, single-entry, Stored ZIP whose header declares
|
|
// `declared_size` as the uncompressed size while the actual stored
|
|
// payload is `payload` — forges an attacker-controlled size field.
|
|
fn build_stored_zip(name: &str, payload: &[u8], declared_size: u32) -> Vec<u8> {
|
|
let name_bytes = name.as_bytes();
|
|
let crc: u32 = {
|
|
// CRC-32 (IEEE) over payload.
|
|
let mut crc = 0xFFFF_FFFFu32;
|
|
for &b in payload {
|
|
crc ^= b as u32;
|
|
for _ in 0..8 {
|
|
let mask = (crc & 1).wrapping_neg();
|
|
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
|
}
|
|
}
|
|
!crc
|
|
};
|
|
let mut out = Vec::new();
|
|
// ----- Local file header -----
|
|
let lfh_offset = out.len() as u32;
|
|
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); // signature
|
|
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // method = Stored
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod time
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod date
|
|
out.extend_from_slice(&crc.to_le_bytes()); // crc-32
|
|
out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // compressed size
|
|
out.extend_from_slice(&declared_size.to_le_bytes()); // uncompressed size (forged)
|
|
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
|
out.extend_from_slice(name_bytes);
|
|
out.extend_from_slice(payload);
|
|
// ----- Central directory header -----
|
|
let cd_offset = out.len() as u32;
|
|
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); // signature
|
|
out.extend_from_slice(&20u16.to_le_bytes()); // version made by
|
|
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // method = Stored
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod time
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // mod date
|
|
out.extend_from_slice(&crc.to_le_bytes());
|
|
out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // compressed size
|
|
out.extend_from_slice(&declared_size.to_le_bytes()); // uncompressed size (forged)
|
|
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // disk number start
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
|
|
out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
|
|
out.extend_from_slice(&lfh_offset.to_le_bytes()); // local header offset
|
|
out.extend_from_slice(name_bytes);
|
|
let cd_size = out.len() as u32 - cd_offset;
|
|
// ----- End of central directory -----
|
|
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); // signature
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // disk number
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // cd start disk
|
|
out.extend_from_slice(&1u16.to_le_bytes()); // entries on this disk
|
|
out.extend_from_slice(&1u16.to_le_bytes()); // total entries
|
|
out.extend_from_slice(&cd_size.to_le_bytes());
|
|
out.extend_from_slice(&cd_offset.to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
|
|
out
|
|
}
|
|
|
|
fn open(bytes: Vec<u8>) -> Jar {
|
|
ZipArchive::new(Cursor::new(bytes)).expect("valid zip")
|
|
}
|
|
|
|
// Pins the exact 64 MiB value as a literal (not derived from the
|
|
// same expression under test) so an arithmetic mutation is caught
|
|
// even though no test builds a full 64 MiB buffer.
|
|
#[test]
|
|
fn max_class_bytes_is_64_mebibytes() {
|
|
assert_eq!(MAX_CLASS_BYTES, 67_108_864);
|
|
}
|
|
|
|
#[test]
|
|
fn has_path_prefix_matches_only_declared_prefix() {
|
|
let jar = open(build_stored_zip(
|
|
"com/dbp/Loader.class",
|
|
MINIMAL_CLASS,
|
|
MINIMAL_CLASS.len() as u32,
|
|
));
|
|
assert!(has_path_prefix(&jar, "com/dbp/"));
|
|
assert!(!has_path_prefix(&jar, "com/bydeluxe/"));
|
|
}
|
|
|
|
#[test]
|
|
fn try_each_class_reads_minimal_class() {
|
|
let mut jar = open(build_stored_zip(
|
|
"Foo.class",
|
|
MINIMAL_CLASS,
|
|
MINIMAL_CLASS.len() as u32,
|
|
));
|
|
let mut seen = Vec::new();
|
|
let r: Option<()> = try_each_class(&mut jar, |name, _class| {
|
|
seen.push(name.to_string());
|
|
None
|
|
});
|
|
assert!(r.is_none());
|
|
assert_eq!(seen, vec!["Foo.class".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn for_each_class_visits_every_class() {
|
|
let mut jar = open(build_stored_zip(
|
|
"Bar.class",
|
|
MINIMAL_CLASS,
|
|
MINIMAL_CLASS.len() as u32,
|
|
));
|
|
let mut count = 0usize;
|
|
for_each_class(&mut jar, |_, _| count += 1);
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
// Attacker-controlled size field (0xFFFF_FFFF ≈ 4 GiB) must not
|
|
// trigger a 4 GiB pre-allocation; incremental read completes and
|
|
// the real (small) payload parses fine.
|
|
#[test]
|
|
fn forged_huge_uncompressed_size_does_not_preallocate() {
|
|
let mut jar = open(build_stored_zip("Evil.class", MINIMAL_CLASS, 0xFFFF_FFFF));
|
|
let mut parsed = false;
|
|
for_each_class(&mut jar, |name, _class| {
|
|
assert_eq!(name, "Evil.class");
|
|
parsed = true;
|
|
});
|
|
// Reached here without OOM/abort, and the real bytes parsed.
|
|
assert!(parsed);
|
|
}
|
|
|
|
// See docs/jar.md — read_is_bounded_by_cap rationale.
|
|
// Read is bounded by MAX_CLASS_BYTES; the entry still parses and
|
|
// is surfaced exactly once despite a forged huge declared size.
|
|
#[test]
|
|
fn read_is_bounded_by_cap() {
|
|
// Padding past MINIMAL_CLASS is harmless trailing data the parser
|
|
// ignores; the point is that read_to_end stops at the cap rather
|
|
// than following a (potentially huge) declared size.
|
|
let mut payload = MINIMAL_CLASS.to_vec();
|
|
payload.extend(std::iter::repeat_n(0u8, 4096));
|
|
let mut jar = open(build_stored_zip(
|
|
"Padded.class",
|
|
&payload,
|
|
// Forge a size far larger than the real payload.
|
|
0xFFFF_FFFF,
|
|
));
|
|
let mut visited = 0usize;
|
|
for_each_class(&mut jar, |_, _| visited += 1);
|
|
assert_eq!(visited, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn try_each_resource_reads_non_class_entries_and_skips_classes() {
|
|
let xml = b"<dcx><disc/></dcx>";
|
|
// A .class entry must be skipped; the .xml resource must be surfaced.
|
|
let mut jar = {
|
|
use std::io::{Cursor, Write as _};
|
|
let mut buf = Vec::new();
|
|
{
|
|
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
|
|
let opts = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Stored);
|
|
w.start_file("com/x/Menu.class", opts).unwrap();
|
|
w.write_all(MINIMAL_CLASS).unwrap();
|
|
w.start_file("00000/dcx.xml", opts).unwrap();
|
|
w.write_all(xml).unwrap();
|
|
w.finish().unwrap();
|
|
}
|
|
ZipArchive::new(Cursor::new(buf)).expect("valid zip")
|
|
};
|
|
|
|
let mut seen: Vec<String> = Vec::new();
|
|
let found: Option<Vec<u8>> = try_each_resource(&mut jar, |name, bytes| {
|
|
seen.push(name.to_string());
|
|
name.ends_with("dcx.xml").then(|| bytes.to_vec())
|
|
});
|
|
assert_eq!(found.as_deref(), Some(&xml[..]));
|
|
assert!(
|
|
!seen.iter().any(|n| n.ends_with(".class")),
|
|
"a .class entry must never be offered as a resource"
|
|
);
|
|
}
|
|
}
|