Files
libfreemkv/src/labels/jar.rs
T
Matthew Jackson 0109db984f fix(#45): BD-J menu-walk — authoritative feature-resolution layer
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.
2026-09-20 20:07:45 -07:00

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"
);
}
}