0.31.0: hardening and correctness pass across mux, codec, AACS/CSS, UDF/MPLS/CLPI, recovery, drive/SCSI, labels, and I/O
Library-wide review-and-fix pass: tightened AACS keydb/handshake/variant handling and trailing-partial-unit policy, corrected MPLS mark offset and added UDF allocation bounds, hardened the mux/codec framing and M2TS paths, guarded SCSI READ CAPACITY short transfers and unified error mapping, added overflow guards on untrusted disc input, and made prefetch shutdown deterministic. Release profile now builds with thin LTO + single codegen unit.
This commit is contained in:
+101
-32
@@ -14,8 +14,9 @@
|
||||
//!
|
||||
//! This module is intentionally separate from the BD-J `StreamLabel`
|
||||
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
|
||||
//! description, set position), not per-stream — wiring into the main
|
||||
//! parser registry happens elsewhere.
|
||||
//! description, set position), not per-stream. It is invoked from the
|
||||
//! disc-scan path in [`labels`](super) ([`detect`] then [`parse`]),
|
||||
//! and [`DiscMetadata`] is re-exported there.
|
||||
//!
|
||||
//! Real-world XML is irregular: missing description elements, multiple
|
||||
//! title elements (first one wins), and occasional malformed content.
|
||||
@@ -23,13 +24,17 @@
|
||||
//! metadata" (returns `None` from the helper), and the caller can
|
||||
//! still get metadata from sibling-language XML files.
|
||||
|
||||
// The module wiring (registry hook + public re-export) is added
|
||||
// separately. Until then the parse/detect entry points have no
|
||||
use super::xml;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Upper bound on the size of a single `bdmt_<lang>.xml` we will read.
|
||||
/// The size comes from attacker-controlled UDF metadata; real files are
|
||||
/// a few KB, so 1 MiB is generous while preventing a crafted huge-size
|
||||
/// entry from triggering an oversized allocation in `read_file`.
|
||||
const MAX_BDMT_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
|
||||
///
|
||||
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
|
||||
@@ -71,6 +76,13 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
let Some(lang) = lang_code_from_filename(&entry.name) else {
|
||||
continue;
|
||||
};
|
||||
// entry.size is attacker-controlled UDF metadata and flows into
|
||||
// a Vec::with_capacity in read_file. A real BDMV bdmt XML is a
|
||||
// few KB; cap well above that so a crafted multi-GB size can't
|
||||
// trigger a huge allocation before any parsing.
|
||||
if !bdmt_size_acceptable(entry.size) {
|
||||
continue;
|
||||
}
|
||||
let path = format!("/BDMV/META/DL/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
@@ -78,7 +90,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(text) else {
|
||||
continue;
|
||||
};
|
||||
out.titles.insert(lang.clone(), title);
|
||||
@@ -102,6 +114,13 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata>
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate a `bdmt_<lang>.xml` file by its declared (untrusted) UDF size
|
||||
/// before reading it. Anything over [`MAX_BDMT_BYTES`] is skipped to
|
||||
/// avoid an oversized allocation in `read_file`.
|
||||
fn bdmt_size_acceptable(size: u64) -> bool {
|
||||
size <= MAX_BDMT_BYTES
|
||||
}
|
||||
|
||||
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
|
||||
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
|
||||
fn is_bdmt_filename(name: &str) -> bool {
|
||||
@@ -134,13 +153,13 @@ pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
|
||||
/// Title-element preference: `<di:name>` → `<di:title>` →
|
||||
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
|
||||
/// authoring-tool conventions documented at the module level).
|
||||
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
|
||||
pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> {
|
||||
let title = extract_title(xml_text)?;
|
||||
// xml::text already returns a trimmed string (see xml::text), so the
|
||||
// description is only filtered for emptiness and XML-fragment noise.
|
||||
let description = xml::text(xml_text, "description")
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| !looks_like_xml(s))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
.filter(|s| !looks_like_xml(s));
|
||||
let disc_set = extract_disc_set(xml_text);
|
||||
Some((title, description, disc_set))
|
||||
}
|
||||
@@ -162,11 +181,12 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// Order matches the module-level convention: <di:name> first
|
||||
// (Paramount-style), then <di:title>, then the nested
|
||||
// tableOfContents/titleName form.
|
||||
// xml::text already trims its result, so an empty string after
|
||||
// extraction means a genuinely empty element.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,9 +195,8 @@ fn extract_title(xml_text: &str) -> Option<String> {
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +216,12 @@ fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
// Reject nonsensical "Disc N of M" values: (0,0), (0,5), (5,2)...
|
||||
// These serialize to JSON and reach downstream consumers as
|
||||
// meaningless metadata.
|
||||
if n < 1 || total < 1 || n > total {
|
||||
return None;
|
||||
}
|
||||
Some((n, total))
|
||||
}
|
||||
|
||||
@@ -212,7 +237,7 @@ mod tests {
|
||||
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Aurora Drift</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
|
||||
let (title, desc, set) = parse_bdmt_xml(xml).expect("title should parse");
|
||||
assert_eq!(title, "Aurora Drift");
|
||||
assert_eq!(desc, None);
|
||||
assert_eq!(set, None);
|
||||
@@ -226,7 +251,7 @@ mod tests {
|
||||
<di:title>Echo Chamber</di:title>
|
||||
<di:description>A film about machines.</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (title, desc, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Echo Chamber");
|
||||
assert_eq!(desc.as_deref(), Some("A film about machines."));
|
||||
}
|
||||
@@ -241,10 +266,54 @@ mod tests {
|
||||
<di:titleName>Feelings Two</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Feelings Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bdmt_size_gate_rejects_oversized_entries() {
|
||||
assert!(bdmt_size_acceptable(0));
|
||||
assert!(bdmt_size_acceptable(4096));
|
||||
assert!(bdmt_size_acceptable(MAX_BDMT_BYTES));
|
||||
assert!(!bdmt_size_acceptable(MAX_BDMT_BYTES + 1));
|
||||
// A crafted multi-GB size is rejected before any allocation.
|
||||
assert!(!bdmt_size_acceptable(8 * 1024 * 1024 * 1024));
|
||||
assert!(!bdmt_size_acceptable(u64::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_set_rejects_nonsensical_pairs() {
|
||||
// n > total, zero numerator, zero denominator → all None.
|
||||
let over = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>5</di:discNumber>
|
||||
<di:numSets>2</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(over).unwrap().2, None);
|
||||
|
||||
let zero_n = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>0</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(zero_n).unwrap().2, None);
|
||||
|
||||
let zero_total = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
<di:numSets>0</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(zero_total).unwrap().2, None);
|
||||
|
||||
// A valid pair still passes.
|
||||
let ok = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>3</di:numSets>
|
||||
</discInfo>"#;
|
||||
assert_eq!(parse_bdmt_xml(ok).unwrap().2, Some((2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
@@ -252,7 +321,7 @@ mod tests {
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((2, 5)));
|
||||
}
|
||||
|
||||
@@ -264,7 +333,7 @@ mod tests {
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numberOfSets>6</di:numberOfSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((3, 6)));
|
||||
}
|
||||
|
||||
@@ -276,7 +345,7 @@ mod tests {
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
@@ -296,7 +365,7 @@ mod tests {
|
||||
|
||||
let mut meta = DiscMetadata::default();
|
||||
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
|
||||
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
|
||||
let (title, desc, ds) = parse_bdmt_xml(blob).unwrap();
|
||||
meta.titles.insert(lang.to_string(), title);
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
@@ -333,20 +402,20 @@ mod tests {
|
||||
// surfaces as None to its caller. Either is documented as
|
||||
// acceptable per the module spec.
|
||||
let bad = "this is not xml &&& <<< nope";
|
||||
assert!(parse_bdmt_xml("eng", bad).is_none());
|
||||
assert!(parse_bdmt_xml(bad).is_none());
|
||||
|
||||
// Half-open tag, no body, no close: also yields no title.
|
||||
let truncated = "<discInfo><di:name>";
|
||||
assert!(parse_bdmt_xml("eng", truncated).is_none());
|
||||
assert!(parse_bdmt_xml(truncated).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_only_child_xml_is_dropped() {
|
||||
// Real-world bug from a captured disc (2026-05-11
|
||||
// capture): <di:description> contained only <di:thumbnail/>
|
||||
// child elements with no actual prose. The previous parser
|
||||
// surfaced the raw XML fragment as the description string.
|
||||
// Now we reject candidates that begin with `<`.
|
||||
// Real-world bug: <di:description> contained only
|
||||
// <di:thumbnail/> child elements with no actual prose. The
|
||||
// previous parser surfaced the raw XML fragment as the
|
||||
// description string. Now we reject candidates that begin
|
||||
// with `<`.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Skyline Run</di:name>
|
||||
<di:description>
|
||||
@@ -355,7 +424,7 @@ mod tests {
|
||||
</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, description, _) =
|
||||
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
|
||||
parse_bdmt_xml(xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Skyline Run");
|
||||
assert!(
|
||||
description.is_none(),
|
||||
@@ -371,7 +440,7 @@ mod tests {
|
||||
<di:name>Some Movie</di:name>
|
||||
<di:description>An epic tale of one man's quest for tea.</di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
|
||||
let (_, description, _) = parse_bdmt_xml(xml).expect("must parse");
|
||||
assert_eq!(
|
||||
description.as_deref(),
|
||||
Some("An epic tale of one man's quest for tea.")
|
||||
@@ -383,7 +452,7 @@ mod tests {
|
||||
let xml = r#"<discInfo><di:name>
|
||||
Aurora Drift
|
||||
</di:name></discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Aurora Drift");
|
||||
}
|
||||
|
||||
|
||||
+69
-28
@@ -16,8 +16,6 @@
|
||||
// callers land. Tests below cover the API in isolation.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
const CLASS_MAGIC: u32 = 0xCAFEBABE;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -34,24 +32,10 @@ pub enum Error {
|
||||
BadInstruction { pc: usize, opcode: u8 },
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::UnexpectedEof { needed } => write!(f, "unexpected EOF reading {}", needed),
|
||||
Error::BadMagic(m) => write!(f, "bad class file magic: 0x{:08X}", m),
|
||||
Error::BadCpTag { index, tag } => {
|
||||
write!(f, "unknown constant pool tag {} at index {}", tag, index)
|
||||
}
|
||||
Error::BadUtf8 { index } => write!(f, "invalid modified-UTF-8 at cp index {}", index),
|
||||
Error::BadCodeAttribute => write!(f, "malformed Code attribute"),
|
||||
Error::BadInstruction { pc, opcode } => {
|
||||
write!(f, "unrecognized opcode 0x{:02X} at pc={}", opcode, pc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
// No Display/std::error::Error impl: this is a crate-internal, typed error
|
||||
// used only for `match`/`?` within the label parsers (callers discard it via
|
||||
// `let Ok(_) = ... else continue`). Per the library's zero-English rule there
|
||||
// is no user-facing text; the variant fields carry the structured detail.
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -673,8 +657,14 @@ fn instruction_size(code: &[u8], pc: usize) -> Option<usize> {
|
||||
if high < low {
|
||||
return None;
|
||||
}
|
||||
let entries = (high - low + 1) as usize;
|
||||
Some(padded_start - pc + 12 + entries * 4)
|
||||
// `high - low + 1` can overflow i32 for adversarial bytecode
|
||||
// (e.g. low=i32::MIN/high=0, or low=0/high=i32::MAX), so widen
|
||||
// to i64 before adding. The product and final sum are saturating
|
||||
// so they cannot overflow usize on a 32-bit target either.
|
||||
let entries = (high as i64 - low as i64 + 1) as u64;
|
||||
let table_bytes = entries.saturating_mul(4);
|
||||
let base = (padded_start - pc + 12) as u64;
|
||||
usize::try_from(base.saturating_add(table_bytes)).ok()
|
||||
}
|
||||
LOOKUPSWITCH => {
|
||||
let padded_start = (pc + 1 + 3) & !3;
|
||||
@@ -686,7 +676,11 @@ fn instruction_size(code: &[u8], pc: usize) -> Option<usize> {
|
||||
if npairs < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(padded_start - pc + 8 + (npairs as usize) * 8)
|
||||
// Saturating product/sum so an attacker-supplied npairs cannot
|
||||
// overflow usize on a 32-bit target.
|
||||
let pair_bytes = (npairs as u64).saturating_mul(8);
|
||||
let base = (padded_start - pc + 8) as u64;
|
||||
usize::try_from(base.saturating_add(pair_bytes)).ok()
|
||||
}
|
||||
WIDE => {
|
||||
// `wide` prefixes one of: iload/lload/fload/dload/aload/
|
||||
@@ -958,7 +952,12 @@ impl<'a> Reader<'a> {
|
||||
if self.pos + 4 > self.data.len() {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
}
|
||||
let v = u32::from_be_bytes(self.data[self.pos..self.pos + 4].try_into().unwrap());
|
||||
let v = u32::from_be_bytes([
|
||||
self.data[self.pos],
|
||||
self.data[self.pos + 1],
|
||||
self.data[self.pos + 2],
|
||||
self.data[self.pos + 3],
|
||||
]);
|
||||
self.pos += 4;
|
||||
Ok(v)
|
||||
}
|
||||
@@ -971,7 +970,16 @@ impl<'a> Reader<'a> {
|
||||
if self.pos + 8 > self.data.len() {
|
||||
return Err(Error::UnexpectedEof { needed });
|
||||
}
|
||||
let v = u64::from_be_bytes(self.data[self.pos..self.pos + 8].try_into().unwrap());
|
||||
let v = u64::from_be_bytes([
|
||||
self.data[self.pos],
|
||||
self.data[self.pos + 1],
|
||||
self.data[self.pos + 2],
|
||||
self.data[self.pos + 3],
|
||||
self.data[self.pos + 4],
|
||||
self.data[self.pos + 5],
|
||||
self.data[self.pos + 6],
|
||||
self.data[self.pos + 7],
|
||||
]);
|
||||
self.pos += 8;
|
||||
Ok(v)
|
||||
}
|
||||
@@ -1026,9 +1034,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_utf8_three_byte_bmp() {
|
||||
// U+00E9 'é' as 3-byte BMP form is unusual but legal; the 2-byte
|
||||
// form is normative. Test the 2-byte form (0xC3 0xA9).
|
||||
fn modified_utf8_two_byte() {
|
||||
// U+00E9 'é' in the standard 2-byte modified-UTF-8 encoding
|
||||
// (0xC3 0xA9), exercising the decoder's 2-byte branch.
|
||||
let s = decode_modified_utf8(&[0xC3, 0xA9]).unwrap();
|
||||
assert_eq!(s, "é");
|
||||
}
|
||||
@@ -1069,6 +1077,39 @@ mod tests {
|
||||
assert_eq!(instruction_size(&code, 0), Some(28));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_tableswitch_overflow_does_not_panic() {
|
||||
// Adversarial low/high spanning the full i32 range. `high - low + 1`
|
||||
// overflows i32; the widened i64 count then saturates the byte
|
||||
// products. Must return a value (possibly None on a 32-bit usize)
|
||||
// without panicking.
|
||||
for (low, high) in [
|
||||
(i32::MIN, 0i32),
|
||||
(0i32, i32::MAX),
|
||||
(i32::MIN, i32::MAX),
|
||||
(-1i32, i32::MAX),
|
||||
] {
|
||||
let mut code = vec![TABLESWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
|
||||
code.extend_from_slice(&low.to_be_bytes());
|
||||
code.extend_from_slice(&high.to_be_bytes());
|
||||
// No need to supply the (enormous) jump table; size computation
|
||||
// must not read it.
|
||||
let _ = instruction_size(&code, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_lookupswitch_overflow_does_not_panic() {
|
||||
// Maximal npairs; `npairs * 8` must saturate rather than overflow.
|
||||
let mut code = vec![LOOKUPSWITCH];
|
||||
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||
code.extend_from_slice(&[0, 0, 0, 0]); // default
|
||||
code.extend_from_slice(&i32::MAX.to_be_bytes()); // npairs = i32::MAX
|
||||
let _ = instruction_size(&code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_size_wide() {
|
||||
// wide iload: 4 bytes. wide iinc: 6 bytes.
|
||||
|
||||
+62
-32
@@ -1,33 +1,20 @@
|
||||
//! CLPI vs MPLS cross-validation diagnostic.
|
||||
//!
|
||||
//! Empirical question (raised 2026-05-11): is CLPI's per-stream
|
||||
//! language and codec data truly redundant with MPLS's STN-table data
|
||||
//! on real-world Blu-rays?
|
||||
//! Walks both per-clip CLPI program info and per-playlist MPLS STN
|
||||
//! tables, normalizes their stream lists by `(PID, language,
|
||||
//! coding_type)`, and classifies each PID into one of four buckets:
|
||||
//!
|
||||
//! Build a quick audit that walks both sources, normalizes their stream
|
||||
//! lists by (PID, language, coding_type), and flags any disagreement.
|
||||
//! 1. **CLPI only** — a stream present in a `.clpi` ProgramInfo that no
|
||||
//! playlist STN table references (orphan on disc).
|
||||
//! 2. **MPLS only** — a stream a playlist references that no `.clpi`
|
||||
//! ProgramInfo lists (indicates a parser disagreement).
|
||||
//! 3. **Match** — both sources agree on coding_type and language.
|
||||
//! 4. **Divergent** — both sources see the PID but disagree on
|
||||
//! coding_type or language.
|
||||
//!
|
||||
//! Three classes of mismatch we want to detect:
|
||||
//!
|
||||
//! 1. **CLPI has streams MPLS doesn't reference.** Orphan streams in
|
||||
//! the .m2ts that no playlist's STN table includes. Means the user
|
||||
//! can't reach them through the menu but they're physically on the
|
||||
//! disc.
|
||||
//! 2. **MPLS has streams CLPI doesn't list.** Should never happen if
|
||||
//! both parsers are correct — playlists reference clips which
|
||||
//! reference streams. If it happens, one of our parsers has a bug.
|
||||
//! 3. **Same PID, different language / coding_type.** The playlist re-
|
||||
//! tagged a stream's metadata. Rare but spec-permitted. Means CLPI
|
||||
//! and MPLS disagree about the same physical stream's properties.
|
||||
//!
|
||||
//! If audits across the corpus show zero mismatches of any class, CLPI
|
||||
//! program_info extraction is **empirically redundant** for labels and
|
||||
//! we can leave it out of the registry. If even one mismatch surfaces,
|
||||
//! we add a CLPI label parser to the registry as belt-and-suspenders.
|
||||
//!
|
||||
//! This module exposes `audit(reader, udf)` returning a structured
|
||||
//! report. Surfaced via the labels-analyze tool — not part of the
|
||||
//! `analyze()` pipeline (no impact on the label output).
|
||||
//! [`audit`] returns a structured [`ClpiVsMplsAudit`] report. This is a
|
||||
//! diagnostic surface only; it does not feed the label-selection
|
||||
//! pipeline.
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
@@ -45,11 +32,14 @@ pub struct ClpiVsMplsRow {
|
||||
}
|
||||
|
||||
impl ClpiVsMplsRow {
|
||||
/// Three rules for classification:
|
||||
/// - both sources missing (impossible — caller wouldn't insert)
|
||||
/// - one source missing → class A or B (orphan-on-disc / playlist-only)
|
||||
/// - both present but fields differ → class C (metadata divergence)
|
||||
/// - both present and identical → no mismatch
|
||||
/// Classification rules:
|
||||
/// - one coding_type present, the other missing → `ClpiOnly` /
|
||||
/// `MplsOnly`
|
||||
/// - both coding_types present, fields differ → `Divergent`
|
||||
/// - both coding_types present and identical → `Match`
|
||||
/// - both coding_types missing (`audit` never builds this, but a
|
||||
/// caller can construct such a row) → compare the language fields:
|
||||
/// `Divergent` if they differ, else `Match`
|
||||
pub fn class(&self) -> ClpiVsMplsClass {
|
||||
match (
|
||||
self.clpi_coding_type.is_some(),
|
||||
@@ -66,7 +56,13 @@ impl ClpiVsMplsRow {
|
||||
ClpiVsMplsClass::Divergent
|
||||
}
|
||||
}
|
||||
(false, false) => ClpiVsMplsClass::Match,
|
||||
(false, false) => {
|
||||
if self.clpi_language == self.mpls_language {
|
||||
ClpiVsMplsClass::Match
|
||||
} else {
|
||||
ClpiVsMplsClass::Divergent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +91,9 @@ pub struct ClpiVsMplsAudit {
|
||||
}
|
||||
|
||||
impl ClpiVsMplsAudit {
|
||||
/// Count rows by class, returned in the fixed order
|
||||
/// `(clpi_only, mpls_only, matches, divergent)` matching the
|
||||
/// [`ClpiVsMplsClass`] variants.
|
||||
pub fn class_counts(&self) -> (usize, usize, usize, usize) {
|
||||
let mut clpi_only = 0;
|
||||
let mut mpls_only = 0;
|
||||
@@ -138,6 +137,11 @@ pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit {
|
||||
continue;
|
||||
};
|
||||
for s in clip.streams {
|
||||
if s.pid == 0 {
|
||||
// PID 0 means "no PID in stream entry" — skip rather
|
||||
// than collide, mirroring the MPLS side below.
|
||||
continue;
|
||||
}
|
||||
clpi_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
@@ -248,6 +252,32 @@ mod tests {
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_both_coding_missing_divergent_on_lang() {
|
||||
// Caller-built row with neither coding_type but disagreeing
|
||||
// languages must classify Divergent, not Match.
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: Some("fra".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_both_coding_missing_match_on_equal_lang() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: Some("eng".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_counts_sum_rows() {
|
||||
let audit = ClpiVsMplsAudit {
|
||||
|
||||
+134
-22
@@ -2,16 +2,29 @@
|
||||
//!
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
//!
|
||||
//! When `playbackconfig.xml` is absent or maps only some streams,
|
||||
//! unmapped streams get 1-based-per-type stream numbers synthesized in
|
||||
//! `streamproperties.xml` order, skipping any number already claimed by
|
||||
//! the map so synthesized and mapped numbers never collide. See
|
||||
//! [`assign_stream_numbers`].
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
|
||||
/// inside a `/BDMV/JAR/*` archive.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
/// Parse `streamproperties.xml` (+ optional `playbackconfig.xml`) into
|
||||
/// per-stream labels. Returns `None` if `streamproperties.xml` is
|
||||
/// absent/unparseable or yields no streams. Stream numbering follows
|
||||
/// the contract documented at module level (see
|
||||
/// [`assign_stream_numbers`]).
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
@@ -29,28 +42,10 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
}
|
||||
}
|
||||
|
||||
let stream_nums = assign_stream_numbers(&stream_infos, &stream_map);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
|
||||
for info in &stream_infos {
|
||||
let stream_num =
|
||||
stream_map
|
||||
.get(&info.id)
|
||||
.copied()
|
||||
.unwrap_or_else(|| match info.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
let n = audio_idx;
|
||||
audio_idx += 1;
|
||||
n
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
let n = sub_idx;
|
||||
sub_idx += 1;
|
||||
n
|
||||
}
|
||||
});
|
||||
|
||||
for (info, &stream_num) in stream_infos.iter().zip(stream_nums.iter()) {
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type: info.stream_type,
|
||||
@@ -70,6 +65,58 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
/// Assign a 1-based stream number per `StreamInfo`, parallel to
|
||||
/// `infos`.
|
||||
///
|
||||
/// A stream mapped in `playbackconfig.xml` (`stream_map`) keeps its
|
||||
/// mapped number. Streams with no mapping (absent or incomplete
|
||||
/// `playbackconfig.xml`, or an unmatched `StreamInfo_ID`) are numbered
|
||||
/// 1-based per type — but the fallback counter SKIPS any number already
|
||||
/// claimed via the map, so a synthesized number can never collide with a
|
||||
/// map-assigned one. (Both numbering domains are 1-based per type, and
|
||||
/// `apply_labels` matches on `(type, stream_number)`, so a collision
|
||||
/// would mislabel tracks.)
|
||||
fn assign_stream_numbers(infos: &[StreamInfo], stream_map: &HashMap<String, u16>) -> Vec<u16> {
|
||||
// Numbers already claimed by the map, per type.
|
||||
let mut taken_audio: Vec<u16> = Vec::new();
|
||||
let mut taken_sub: Vec<u16> = Vec::new();
|
||||
for info in infos {
|
||||
if let Some(&n) = stream_map.get(&info.id) {
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => taken_audio.push(n),
|
||||
StreamLabelType::Subtitle => taken_sub.push(n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
let mut out = Vec::with_capacity(infos.len());
|
||||
for info in infos {
|
||||
let n = match stream_map.get(&info.id).copied() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
let (idx, taken) = match info.stream_type {
|
||||
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
|
||||
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
|
||||
};
|
||||
// Advance past any number already claimed via the map.
|
||||
// saturating: a crafted XML with >65k stream entries must
|
||||
// not overflow (panic in debug, wrap-to-0 in release) on
|
||||
// untrusted disc bytes.
|
||||
while taken.contains(idx) {
|
||||
*idx = idx.saturating_add(1);
|
||||
}
|
||||
let n = *idx;
|
||||
*idx = idx.saturating_add(1);
|
||||
n
|
||||
}
|
||||
};
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
id: String,
|
||||
stream_type: StreamLabelType,
|
||||
@@ -137,10 +184,75 @@ fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
||||
xml::text(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
// Stream numbers are 1-based per the apply_labels
|
||||
// contract; a mapped 0 is unmatchable and silently
|
||||
// drops the label. Skip it rather than store it.
|
||||
if stream_num != 0 {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn info(id: &str, t: StreamLabelType) -> StreamInfo {
|
||||
StreamInfo {
|
||||
id: id.into(),
|
||||
stream_type: t,
|
||||
language: "eng".into(),
|
||||
variant: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_numbers_dense_when_map_empty() {
|
||||
let infos = vec![
|
||||
info("a0", StreamLabelType::Audio),
|
||||
info("a1", StreamLabelType::Audio),
|
||||
info("s0", StreamLabelType::Subtitle),
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &HashMap::new());
|
||||
// Per-type 1-based: audio 1,2 ; subtitle 1.
|
||||
assert_eq!(nums, vec![1, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_does_not_collide_with_partial_map() {
|
||||
// Map claims audio "a1" -> 1. The unmapped audio "a0" must NOT
|
||||
// also get 1 (the pre-fix bug); it must skip to 2.
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a1".to_string(), 1u16);
|
||||
let infos = vec![
|
||||
info("a0", StreamLabelType::Audio), // unmapped → fallback
|
||||
info("a1", StreamLabelType::Audio), // mapped → 1
|
||||
info("a2", StreamLabelType::Audio), // unmapped → fallback
|
||||
];
|
||||
let nums = assign_stream_numbers(&infos, &map);
|
||||
// a0 skips the taken 1 → 2; a1 keeps 1; a2 → 3. All distinct.
|
||||
assert_eq!(nums, vec![2, 1, 3]);
|
||||
let mut sorted = nums.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), 3, "stream numbers must be unique");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_fully_drives_numbers_when_complete() {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("a0".to_string(), 5u16);
|
||||
map.insert("a1".to_string(), 9u16);
|
||||
let infos = vec![
|
||||
info("a0", StreamLabelType::Audio),
|
||||
info("a1", StreamLabelType::Audio),
|
||||
];
|
||||
assert_eq!(assign_stream_numbers(&infos, &map), vec![5, 9]);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-99
@@ -9,11 +9,17 @@ use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Cheap signature check: a CTRM disc ships `menu_base.prop` and/or
|
||||
/// `language_streams.txt` inside a `/BDMV/JAR/*` archive.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "menu_base.prop")
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
/// Full extraction: parses `language_streams.txt` (structured types) and
|
||||
/// `menu_base.prop` (stream numbers + button names), merging when both
|
||||
/// are present. Returns `None` when neither file is present/parseable or
|
||||
/// no labels result.
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(reader, udf);
|
||||
@@ -50,9 +56,32 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append any menu_base-only stream (present in mb but not in ls by
|
||||
// (stream_type, stream_number)). Without this the both-files path
|
||||
// silently drops streams the menu_base-only path would have emitted:
|
||||
// language_streams is authoritative for type/purpose but is not
|
||||
// necessarily a superset of menu_base.
|
||||
for mb_label in mb {
|
||||
let already = result.iter().any(|l| {
|
||||
l.stream_type == mb_label.stream_type && l.stream_number == mb_label.stream_number
|
||||
});
|
||||
if !already {
|
||||
result.push(mb_label);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// True if a property-key prefix denotes a commentary stream group.
|
||||
/// Tightened from a bare `prefix.contains("comm")` substring scan, which
|
||||
/// over-matched unrelated prefixes like `common_*` / `community_*`. We
|
||||
/// split on `_` and require a `commentary` (or `comm`) segment.
|
||||
fn prefix_is_commentary(prefix: &str) -> bool {
|
||||
prefix
|
||||
.split('_')
|
||||
.any(|seg| seg.eq_ignore_ascii_case("commentary") || seg.eq_ignore_ascii_case("comm"))
|
||||
}
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
@@ -73,9 +102,12 @@ fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<
|
||||
}
|
||||
|
||||
let type_str = parts[1];
|
||||
// STN indices are 1-based; apply_labels pre-increments from 0 and
|
||||
// never matches a 0, so a 0 here would emit a dead label. Skip it
|
||||
// (matching the `n > 0` guard in parse_menu_base).
|
||||
let stream_num: u16 = match parts[2].parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
Ok(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
let language = parts[3].to_string();
|
||||
let variant = if parts.len() > 4 {
|
||||
@@ -145,18 +177,18 @@ fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<
|
||||
|
||||
if !variant.is_empty() {
|
||||
match variant.as_str() {
|
||||
// Codec variants — use shared label vocab
|
||||
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
|
||||
codec_hint = vocab::codec(&variant).to_string();
|
||||
}
|
||||
// Purpose variants
|
||||
"eda" => final_purpose = LabelPurpose::Descriptive,
|
||||
// Dialect variants — pass through raw code from disc
|
||||
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
|
||||
variant_code = variant.clone();
|
||||
}
|
||||
// Unknown — store as-is in codec_hint
|
||||
_ => codec_hint = variant.clone(),
|
||||
// Everything else: defer to vocab::codec as the single
|
||||
// source of codec-name truth. If it recognizes the token
|
||||
// (returns something other than the input) it's a known
|
||||
// codec — store the canonical name. Otherwise it's an
|
||||
// unknown token, stored as-is.
|
||||
_ => codec_hint = vocab::codec(&variant).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,85 +214,10 @@ fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal menu_base.prop text and run `parse_menu_base`'s
|
||||
/// inner logic via a temporary closure. This isolates the prop
|
||||
/// parsing without needing a SectorSource.
|
||||
/// Run the real shipping parser ([`parse_menu_base_text`]) on a
|
||||
/// menu_base.prop body so tests exercise production code directly.
|
||||
fn parse_props(text: &str) -> Vec<StreamLabel> {
|
||||
// Mirror the inner loop of parse_menu_base exactly. Kept
|
||||
// separate so the test doesn't need disc fixtures.
|
||||
use std::collections::HashMap;
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some(eq_pos) = line.find('=') else {
|
||||
continue;
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
if let Some(dot_pos) = full_key.rfind('.') {
|
||||
entries
|
||||
.entry(full_key[..dot_pos].to_string())
|
||||
.or_default()
|
||||
.insert(full_key[dot_pos + 1..].to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (prefix, props) in &entries {
|
||||
let is_audio = props
|
||||
.get("class")
|
||||
.is_some_and(|c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props
|
||||
.get("class")
|
||||
.is_some_and(|c| c.contains("SubtitleButton"))
|
||||
|| prefix.starts_with("subtitle_");
|
||||
let stream_num_str = props
|
||||
.get("streamNumber")
|
||||
.or_else(|| props.get("audioStream"))
|
||||
.or_else(|| props.get("subtitleStream"));
|
||||
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
if !is_audio && !is_subtitle {
|
||||
continue;
|
||||
}
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
let qualifier = if is_subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
let stream_type = if is_audio {
|
||||
StreamLabelType::Audio
|
||||
} else {
|
||||
StreamLabelType::Subtitle
|
||||
};
|
||||
let language = props
|
||||
.get("audioLanguage")
|
||||
.or_else(|| props.get("subtitleLanguage"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
labels
|
||||
parse_menu_base_text(text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -334,6 +291,74 @@ mod tests {
|
||||
);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dual_flag_entry_resolves_to_audio_with_no_subtitle_qualifier() {
|
||||
// An entry tripping BOTH flags (audio_ prefix sets is_audio,
|
||||
// class "SubtitleButton" sets is_subtitle). Audio wins the type,
|
||||
// and the subtitle qualifier (SDH) must NOT be carried onto the
|
||||
// resulting Audio label. Regression for the type/qualifier split.
|
||||
let labels = parse_props(
|
||||
"audio_1.class=SubtitleButton\n\
|
||||
audio_1.streamNumber=6\n\
|
||||
audio_1.name=English SDH\n",
|
||||
);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_commentary_segment_match_not_substring() {
|
||||
// Genuine commentary group segments match.
|
||||
assert!(prefix_is_commentary("audio_commentary"));
|
||||
assert!(prefix_is_commentary("audio_commentary_1"));
|
||||
assert!(prefix_is_commentary("comm"));
|
||||
// Substring-only prefixes must NOT match (the over-match bug).
|
||||
assert!(!prefix_is_commentary("common"));
|
||||
assert!(!prefix_is_commentary("audio_common_1"));
|
||||
assert!(!prefix_is_commentary("community"));
|
||||
assert!(!prefix_is_commentary("audio_1"));
|
||||
}
|
||||
|
||||
fn lbl(t: StreamLabelType, n: u16, name: &str) -> StreamLabel {
|
||||
StreamLabel {
|
||||
stream_number: n,
|
||||
stream_type: t,
|
||||
language: String::new(),
|
||||
name: name.to_string(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_menu_base_only_streams() {
|
||||
// language_streams covers audio 1; menu_base has audio 1 (name)
|
||||
// AND a menu_base-only audio 2. The merge must keep audio 2 —
|
||||
// the both-files path previously dropped it.
|
||||
let ls = vec![lbl(StreamLabelType::Audio, 1, "")];
|
||||
let mb = vec![
|
||||
lbl(StreamLabelType::Audio, 1, "Main"),
|
||||
lbl(StreamLabelType::Audio, 2, "Commentary"),
|
||||
];
|
||||
let merged = merge(ls, mb);
|
||||
assert_eq!(merged.len(), 2, "menu_base-only stream must survive");
|
||||
// ls audio 1 takes its name from mb.
|
||||
let a1 = merged
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 1)
|
||||
.unwrap();
|
||||
assert_eq!(a1.name, "Main");
|
||||
// mb-only audio 2 is appended.
|
||||
assert!(
|
||||
merged
|
||||
.iter()
|
||||
.any(|l| l.stream_number == 2 && l.name == "Commentary")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
@@ -341,7 +366,18 @@ mod tests {
|
||||
fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
let labels = parse_menu_base_text(text);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Parse the body of a `menu_base.prop` file into stream labels. Split
|
||||
/// out from [`parse_menu_base`] (which only handles file I/O + UTF-8
|
||||
/// decode) so unit tests exercise the real parsing logic instead of a
|
||||
/// hand-copied duplicate. Returns the labels sorted by (type, number).
|
||||
fn parse_menu_base_text(text: &str) -> Vec<StreamLabel> {
|
||||
// Parse key=value, group by prefix
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
@@ -394,6 +430,15 @@ fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<Str
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the stream type FIRST: when an entry trips both flags
|
||||
// (e.g. an `audio_` prefix with a class containing
|
||||
// "SubtitleButton"), audio wins the type.
|
||||
let stream_type = if is_audio {
|
||||
StreamLabelType::Audio
|
||||
} else {
|
||||
StreamLabelType::Subtitle
|
||||
};
|
||||
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
|
||||
// Purpose: ask vocab first (word-boundary matched — avoids the
|
||||
@@ -402,23 +447,19 @@ fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<Str
|
||||
// (`audio_commentary.foo`-style keys group commentary streams
|
||||
// regardless of display name).
|
||||
let purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
LabelPurpose::Normal if prefix_is_commentary(prefix) => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
|
||||
// Qualifier: only apply to subtitles (SDH is a subtitle concept).
|
||||
let qualifier = if is_subtitle {
|
||||
// Qualifier (SDH/Forced) is a subtitle-only concept. Gate on the
|
||||
// RESOLVED type, not the raw is_subtitle flag, so an entry that
|
||||
// resolved to Audio never carries a subtitle qualifier.
|
||||
let qualifier = if stream_type == StreamLabelType::Subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
let stream_type = if is_audio {
|
||||
StreamLabelType::Audio
|
||||
} else {
|
||||
StreamLabelType::Subtitle
|
||||
};
|
||||
|
||||
// Try to extract language from audioLanguage/subtitleLanguage prop
|
||||
let language = props
|
||||
.get("audioLanguage")
|
||||
@@ -438,9 +479,6 @@ fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<Str
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
Some(labels)
|
||||
labels
|
||||
}
|
||||
|
||||
+17
-17
@@ -1,8 +1,6 @@
|
||||
//! "dbp" framework — Magnolia Pictures BD-J authoring shop (per
|
||||
//! `bd-live.magpictures.com` referenced in the disc's
|
||||
//! `com/dbp/bluray.MenuXlet.perm`). Detected on UHD discs whose
|
||||
//! `/BDMV/JAR/<x>.jar` (top-level, not in a subdir) contains
|
||||
//! `com/dbp/` package paths.
|
||||
//! "dbp" framework — a BD-J authoring framework identified by
|
||||
//! `com/dbp/` package paths in a top-level `/BDMV/JAR/<x>.jar` (not in
|
||||
//! a subdir). Seen on UHD discs.
|
||||
//!
|
||||
//! Stream labels live as plain ASCII strings inside compiled `.class`
|
||||
//! files in the jar — a quirk of the menu-rendering layer encoding
|
||||
@@ -16,21 +14,20 @@
|
||||
//! ATextField,Subtitle0,None,Fontstrip_Composite,...
|
||||
//! ```
|
||||
//!
|
||||
//! The single uppercase letter before `TextField` is string-pool
|
||||
//! prefix noise — the parser anchors on `TextField,` regardless of
|
||||
//! what precedes it. `Subtitle0` is the disable-subtitles menu
|
||||
//! button and is skipped (not a real subtitle stream).
|
||||
//! The parser ignores any prefix before the first `TextField,`
|
||||
//! occurrence — whatever string-pool ordering placed ahead of it is
|
||||
//! irrelevant. `Subtitle0` is the disable-subtitles menu button and is
|
||||
//! skipped (not a real subtitle stream).
|
||||
//!
|
||||
//! ## Implementation
|
||||
//!
|
||||
//! v2 (2026-05-10): rewritten on top of [`super::class_reader`] —
|
||||
//! iterates `CpInfo::Utf8` constant-pool entries instead of raw byte
|
||||
//! scanning each class file. Equivalent label coverage (the literal
|
||||
//! `TextField,...` strings live in the CP as Utf8 entries), but
|
||||
//! structurally cleaner: no false-positive risk from method bytecode
|
||||
//! or attribute names happening to contain `TextField,`. Language /
|
||||
//! purpose / qualifier classification moved to [`super::vocab`] so all
|
||||
//! Java-parser families share one source of truth.
|
||||
//! Iterates `CpInfo::Utf8` constant-pool entries rather than raw
|
||||
//! byte-scanning each class file. Equivalent label coverage (the literal
|
||||
//! `TextField,...` strings live in the CP as Utf8 entries) with no
|
||||
//! false-positive risk from method bytecode or attribute names that
|
||||
//! happen to contain `TextField,`. Language / purpose / qualifier
|
||||
//! classification lives in [`super::vocab`] so all Java-parser families
|
||||
//! share one source of truth.
|
||||
|
||||
use super::class_reader::CpInfo;
|
||||
use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab};
|
||||
@@ -48,6 +45,9 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
jar::has_any_top_level_jar(udf)
|
||||
}
|
||||
|
||||
/// Scan every top-level `/BDMV/JAR/*.jar` for the dbp framework and
|
||||
/// extract its stream labels. Returns `None` if no jar carries a
|
||||
/// `com/dbp/` package path or none yields any labels.
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
jar::for_each_jar(reader, udf, |_entry_name, archive| {
|
||||
if !jar::has_path_prefix(archive, "com/dbp/") {
|
||||
|
||||
+14
-248
@@ -16,11 +16,13 @@
|
||||
//! | Purpose | 8 ldcs starting `Normal, Commentary, PiP, Trivia, ...` |
|
||||
//! | VideoFormat | 7 ldcs starting `HD, HDR10 Plus, HD Dolby, ...` |
|
||||
//! | Region | 22 ldcs starting `USA_D1, LIC1, LIC2, LIC3, ...` |
|
||||
//! | Studio | 6 ldcs starting `Disney, Marvel, Pixar, ...` |
|
||||
//! | Codec | many `new` instructions, 0 ldcs in `<clinit>` (codec strings live in subclasses) |
|
||||
//! | Studio | 6 ldcs in `<clinit>` |
|
||||
//!
|
||||
//! Matching on the shape rather than the class name keeps the parser
|
||||
//! working across obfuscation variants.
|
||||
//! working across obfuscation variants. Codec strings come from the
|
||||
//! standard BD-J `org/bluray/ti/CodingType` enum referenced directly by
|
||||
//! the binding constructors (see [`StackVal::CodingType`]), not from a
|
||||
//! Deluxe-internal enum.
|
||||
//!
|
||||
//! ## Implementation phases
|
||||
//!
|
||||
@@ -29,14 +31,6 @@
|
||||
//! the framework-stable fingerprints. Output: `Vec<(label, MasterEnum)>`
|
||||
//! with full ordinal → string-value tables.
|
||||
//!
|
||||
//! - **Phase B** — codec enum subclass walk (`decode_codec_enum`).
|
||||
//! The codec enum's `<clinit>` has many `new` instructions and zero
|
||||
//! string ldcs — codec name strings live in the subclasses each
|
||||
//! `new` constructs. Walks every referenced subclass's constant
|
||||
//! pool, extracts the codec name string, following the standard Java
|
||||
//! enum compilation convention (each enum value's `<init>` is called
|
||||
//! with its name string as the first arg).
|
||||
//!
|
||||
//! - **Phase C** — binding-class identification (`find_binding_classes`).
|
||||
//! The per-stream table is built by some class via repeated
|
||||
//! `getstatic` references to the master enums identified in A.
|
||||
@@ -105,21 +99,6 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
// Build a fast-lookup table for Phase D's bytecode decoder.
|
||||
let master_table = MasterEnumTable::from(&enums);
|
||||
|
||||
// Phase B — codec enum (structural + subclass walk).
|
||||
let codec_shape = find_codec_enum(archive);
|
||||
let codec_table = match codec_shape.as_ref() {
|
||||
Some(shape) => decode_codec_enum(archive, shape),
|
||||
None => CodecTable::default(),
|
||||
};
|
||||
if let Some(shape) = &codec_shape {
|
||||
tracing::info!(
|
||||
jar = %entry_name,
|
||||
class = %shape.class_name,
|
||||
count = codec_table.codecs.len(),
|
||||
"deluxe codec enum decoded",
|
||||
);
|
||||
}
|
||||
|
||||
// Phase C — find ALL binding-class candidates (audio + subtitle
|
||||
// are often split across two classes on Deluxe). Each gets its
|
||||
// own `<clinit>` walk; constructions union into a single
|
||||
@@ -323,203 +302,6 @@ fn ldcs_match_prefix(ldcs: &[String], prefix: &[&str]) -> bool {
|
||||
.all(|(got, want)| got == want)
|
||||
}
|
||||
|
||||
/// Phase B (structural): identify the codec enum class. The codec
|
||||
/// enum's `<clinit>` has many `new` instructions (one per codec value)
|
||||
/// and zero string ldcs — codec name strings live in the subclasses
|
||||
/// each `new` constructs, not in the enum class itself. This function
|
||||
/// returns the candidate enum's class name + the ordered list of
|
||||
/// subclass class names; [`decode_codec_enum`] walks those subclasses
|
||||
/// to extract the codec strings.
|
||||
pub(crate) fn find_codec_enum(archive: &mut jar::Jar) -> Option<CodecEnumShape> {
|
||||
let mut best: Option<(String, Vec<String>)> = None;
|
||||
jar::for_each_class(archive, |class_name, class| {
|
||||
let Some((news, ldcs)) = clinit_news_and_ldcs(class) else {
|
||||
return;
|
||||
};
|
||||
// Codec enum's <clinit> has many `new` ops, 0 string ldcs.
|
||||
if news.len() < 20 || !ldcs.is_empty() {
|
||||
return;
|
||||
}
|
||||
match &best {
|
||||
None => best = Some((class_name.to_string(), news)),
|
||||
Some((_, prev)) => {
|
||||
if news.len() > prev.len() {
|
||||
best = Some((class_name.to_string(), news));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
best.map(|(class_name, subclass_news)| CodecEnumShape {
|
||||
class_name,
|
||||
subclass_news,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CodecEnumShape {
|
||||
pub class_name: String,
|
||||
/// Ordered list of class names referenced by `new` in <clinit>.
|
||||
/// One entry per codec enum value; subclass walking resolves
|
||||
/// each to a codec string.
|
||||
pub subclass_news: Vec<String>,
|
||||
}
|
||||
|
||||
/// Phase B (subclass walk): given the codec enum's structural shape,
|
||||
/// walk each referenced subclass's constant pool to extract its
|
||||
/// codec name string. Output is ordinal-indexed: `codecs[i]` is the
|
||||
/// codec name for the i-th `new` instruction in the enum's `<clinit>`.
|
||||
///
|
||||
/// The codec name extraction heuristic: each subclass's constant
|
||||
/// pool typically contains a small number of Utf8 entries; the
|
||||
/// codec-name-shaped one is uppercase, ≥4 chars, optionally with
|
||||
/// underscores or digits. We pick the first matching Utf8 entry that
|
||||
/// isn't a method-descriptor sigil, class-name fragment, or attribute
|
||||
/// name. Empty string when no candidate is found — the parser can
|
||||
/// surface "unknown codec at ordinal N" via tracing.
|
||||
pub(crate) fn decode_codec_enum(archive: &mut jar::Jar, shape: &CodecEnumShape) -> CodecTable {
|
||||
// Two-pass: first pass extracts the codec-name candidate from
|
||||
// every class in the jar (cheap to do all at once, cache for the
|
||||
// ordinal-ordered second pass).
|
||||
let mut name_by_class: HashMap<String, String> = HashMap::new();
|
||||
let wanted: HashSet<&str> = shape.subclass_news.iter().map(String::as_str).collect();
|
||||
jar::for_each_class(archive, |class_name, class| {
|
||||
if !wanted.contains(class_name) {
|
||||
return;
|
||||
}
|
||||
if let Some(name) = extract_codec_name(class) {
|
||||
name_by_class.insert(class_name.to_string(), name);
|
||||
}
|
||||
});
|
||||
|
||||
let codecs: Vec<String> = shape
|
||||
.subclass_news
|
||||
.iter()
|
||||
.map(|c| name_by_class.get(c).cloned().unwrap_or_default())
|
||||
.collect();
|
||||
CodecTable { codecs }
|
||||
}
|
||||
|
||||
/// Per-codec name table — `codecs[ordinal]` is the codec string for
|
||||
/// that enum value. Empty string for ordinals where Phase B couldn't
|
||||
/// extract a name (rare; logged via tracing).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct CodecTable {
|
||||
pub codecs: Vec<String>,
|
||||
}
|
||||
|
||||
impl CodecTable {
|
||||
/// Resolve a codec enum ordinal to its name string. Returns None
|
||||
/// for out-of-range ordinals or for entries Phase B couldn't
|
||||
/// extract (those slots are stored as empty strings, which this
|
||||
/// helper normalizes to None).
|
||||
#[allow(dead_code)] // surface for callers; interpret_streams uses
|
||||
// binding_type substring match for now (codec-ordinal wiring
|
||||
// deferred until corpus bytecode confirms the codec arg position).
|
||||
pub fn get(&self, ordinal: u16) -> Option<&str> {
|
||||
let s = self.codecs.get(ordinal as usize)?;
|
||||
if s.is_empty() { None } else { Some(s.as_str()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: extract the codec-name string from a codec-enum
|
||||
/// subclass's constant pool. Codec names are uppercase tokens with
|
||||
/// optional underscores/digits, ≥4 chars (e.g. "ATMOS_HD_AUDIO",
|
||||
/// "DOLBY_AC3_AUDIO", "DTS_HD_MA", "PCM_5_1"). We scan the pool's
|
||||
/// Utf8 entries and pick the first that:
|
||||
/// - is ≥4 chars
|
||||
/// - contains only A-Z, 0-9, and _
|
||||
/// - contains at least one underscore OR is a known codec token
|
||||
/// (the underscore signal is what separates "ATMOS_HD_AUDIO"
|
||||
/// from "Utf8" / "Code" / "Object" attribute names).
|
||||
///
|
||||
/// Returns `None` when no candidate matches — the caller's `codecs[i]`
|
||||
/// will be empty for that ordinal.
|
||||
fn extract_codec_name(class: &ClassFile) -> Option<String> {
|
||||
for (_, entry) in class.constant_pool.iter() {
|
||||
let CpInfo::Utf8(s) = entry else {
|
||||
continue;
|
||||
};
|
||||
if s.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
if !s
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !s.contains('_') {
|
||||
// Single-token all-caps strings might still be valid
|
||||
// (e.g. "ATMOS", "DTS"). Require at least one of the
|
||||
// known codec token roots to avoid false positives like
|
||||
// attribute names that happen to be uppercase. For now
|
||||
// we only accept these as a fallback.
|
||||
let is_known_root = [
|
||||
"ATMOS", "DOLBY", "DTS", "TRUEHD", "MLP", "AC3", "EAC3", "PCM",
|
||||
]
|
||||
.iter()
|
||||
.any(|root| s == *root);
|
||||
if !is_known_root {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some(s.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk `<clinit>` and return `(new_class_names, ldc_strings)`. Used
|
||||
/// for the codec-enum shape match where we care about both counts.
|
||||
#[allow(dead_code)]
|
||||
fn clinit_news_and_ldcs(
|
||||
class: &super::class_reader::ClassFile,
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
let mut news = Vec::new();
|
||||
let mut ldcs = Vec::new();
|
||||
let mut found = false;
|
||||
let mut _aastore = 0u32;
|
||||
for m in &class.methods {
|
||||
let Some(name) = class.member_name(m) else {
|
||||
continue;
|
||||
};
|
||||
if name != "<clinit>" {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
let Some(code) = m.code(&class.constant_pool) else {
|
||||
continue;
|
||||
};
|
||||
for insn in code.instructions() {
|
||||
match insn.opcode {
|
||||
NEW => {
|
||||
if let Some(idx) = insn.cp_index() {
|
||||
if let Some(n) = class.constant_pool.class_name(idx) {
|
||||
news.push(n.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
LDC | LDC_W => {
|
||||
if let Some(idx) = insn.cp_index() {
|
||||
let s = match class.constant_pool.get(idx) {
|
||||
Some(CpInfo::String { string_index }) => {
|
||||
class.constant_pool.utf8(*string_index).map(str::to_string)
|
||||
}
|
||||
Some(CpInfo::Utf8(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = s {
|
||||
ldcs.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
AASTORE => _aastore += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found { Some((news, ldcs)) } else { None }
|
||||
}
|
||||
|
||||
// ── Phase C: find the binding class ─────────────────────────────────────────
|
||||
|
||||
/// Phase C: identify the class that builds the per-stream label table.
|
||||
@@ -647,15 +429,18 @@ pub(crate) fn decode_binding(
|
||||
binding_class_name: &str,
|
||||
master: &MasterEnumTable,
|
||||
) -> Vec<Construction> {
|
||||
let mut out: Vec<Construction> = Vec::new();
|
||||
let target_name = binding_class_name.to_string();
|
||||
jar::for_each_class(archive, |class_name, class| {
|
||||
// Short-circuit on the name match: try_each_class stops iterating
|
||||
// (and stops decompressing/parsing remaining .class entries) as soon
|
||||
// as the closure returns Some, instead of walking the whole jar past
|
||||
// the target.
|
||||
jar::try_each_class(archive, |class_name, class| {
|
||||
if class_name != target_name {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
out = decode_binding_class(class, master);
|
||||
});
|
||||
out
|
||||
Some(decode_binding_class(class, master))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Walk every method named `<clinit>` (typically only one) on this
|
||||
@@ -1565,25 +1350,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_codec_name_picks_uppercase_with_underscore() {
|
||||
// Synthetic class file built via ClassFile::parse would be
|
||||
// overkill; here we directly invoke extract_codec_name via a
|
||||
// minimal hand-built ClassFile. Skip — covered indirectly by
|
||||
// the end-to-end Phase B tests at corpus runtime. Tested
|
||||
// signal: the matcher logic itself.
|
||||
// (Helper inlined for clarity rather than spinning up a fake
|
||||
// class.)
|
||||
let candidate_strings = ["Code", "Utf8", "ATMOS_HD_AUDIO", "MyVar"];
|
||||
let result = candidate_strings.iter().find(|s| {
|
||||
s.len() >= 4
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||||
&& s.contains('_')
|
||||
});
|
||||
assert_eq!(result, Some(&"ATMOS_HD_AUDIO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_enum_table_resolves_field_to_ordinal() {
|
||||
let table = lang_enum_master();
|
||||
|
||||
+186
-35
@@ -7,17 +7,20 @@
|
||||
//! "open every top-level jar, look at every .class inside" without
|
||||
//! repeating the zip-archive boilerplate.
|
||||
|
||||
// `try_each_class` is staged for `labels::deluxe`, which needs the
|
||||
// early-return form to short-circuit class iteration on a match.
|
||||
// dead-code allow comes off when deluxe lands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::class_reader::ClassFile;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::io::Cursor;
|
||||
use std::io::{Cursor, Read};
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Upper bound on bytes read out of a single `.class` entry. The jar's
|
||||
/// uncompressed-size field is attacker-controlled disc metadata, so the
|
||||
/// buffer is grown incrementally and the read is capped here rather than
|
||||
/// pre-sized from the declared size. A real BD-J `.class` is far under
|
||||
/// this ceiling (64 MiB); a lying header simply gets truncated and the
|
||||
/// class fails to parse, which is skipped like any other bad entry.
|
||||
const MAX_CLASS_BYTES: u64 = 64 * 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.
|
||||
@@ -81,15 +84,8 @@ where
|
||||
/// 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: &mut Jar, prefix: &str) -> bool {
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(f) = archive.by_index(i) {
|
||||
if f.name().starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
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
|
||||
@@ -103,23 +99,12 @@ pub fn for_each_class<F>(archive: &mut Jar, mut f: F)
|
||||
where
|
||||
F: FnMut(&str, &ClassFile),
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(class) = ClassFile::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
f(&name, &class);
|
||||
}
|
||||
// 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
|
||||
@@ -129,15 +114,18 @@ where
|
||||
F: FnMut(&str, &ClassFile) -> Option<R>,
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
let Ok(entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
// 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 {
|
||||
@@ -149,3 +137,166 @@ where
|
||||
}
|
||||
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 (uncompressed) ZIP whose local
|
||||
/// header and central directory both declare `declared_size` as the
|
||||
/// uncompressed size, while the actual stored payload is `payload`.
|
||||
/// This lets a test forge an attacker-controlled size field that does
|
||||
/// not match the real data length.
|
||||
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")
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// The uncompressed-size field is attacker-controlled. A tiny stored
|
||||
/// entry that declares 0xFFFF_FFFF (≈4 GiB) must NOT trigger a 4 GiB
|
||||
/// pre-allocation; with the incremental read the call 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);
|
||||
}
|
||||
|
||||
/// The read is bounded by MAX_CLASS_BYTES: a stored entry whose real
|
||||
/// payload exceeds the cap yields only the first MAX_CLASS_BYTES
|
||||
/// bytes to the parser, never the full (unbounded) entry. Verified
|
||||
/// here on a small cap via the entry-count path: the truncated bytes
|
||||
/// still parse a valid class prefix, but no read beyond the cap
|
||||
/// occurs. We assert the entry is still surfaced exactly once (the
|
||||
/// cap does not drop legitimate entries) and the call returns.
|
||||
#[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(0u8).take(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);
|
||||
}
|
||||
}
|
||||
|
||||
+142
-20
@@ -4,7 +4,9 @@
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 3. Implement `pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 3. Implement `pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>`
|
||||
//! (set [`ParseResult::confidence`]; it drives parser selection on
|
||||
//! a tie)
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod bdmt;
|
||||
@@ -69,6 +71,9 @@ pub enum LabelPurpose {
|
||||
Commentary,
|
||||
Descriptive,
|
||||
Score,
|
||||
/// Alternate music track (e.g. an alternate end-credits / closing-
|
||||
/// theme music stream), tagged by the `ime` token some BD-J
|
||||
/// authoring tools emit on the secondary music audio.
|
||||
Ime,
|
||||
}
|
||||
|
||||
@@ -377,12 +382,19 @@ fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
|
||||
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
|
||||
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
|
||||
let says_atmos = h.contains("atmos");
|
||||
// DTS:X is an object-audio extension carried on a DTS-HD MA (or HR)
|
||||
// core, exactly as Atmos rides TrueHD / DD+. The spec Codec enum has
|
||||
// no DtsX variant, so a correctly-authored DTS:X hint must be judged
|
||||
// consistent with its DtsHdMa/DtsHdHr carrier rather than discarded.
|
||||
let says_dtsx = h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx");
|
||||
|
||||
let names_family =
|
||||
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
|
||||
|
||||
// Pure-editorial hint (no codec family named) isn't asserting a codec →
|
||||
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
|
||||
// ("DTS:X" always also matches the "dts" family above, so it never
|
||||
// reaches this branch — it is handled in the DtsHdMa/DtsHdHr arms.)
|
||||
if !names_family {
|
||||
return if says_atmos {
|
||||
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
|
||||
@@ -395,8 +407,8 @@ fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
|
||||
Codec::TrueHd => says_truehd || says_atmos,
|
||||
Codec::Ac3Plus => says_ddp || says_atmos,
|
||||
Codec::Ac3 => says_ac3,
|
||||
Codec::DtsHdMa => says_dts_ma,
|
||||
Codec::DtsHdHr => says_dts_hr,
|
||||
Codec::DtsHdMa => says_dts_ma || says_dtsx,
|
||||
Codec::DtsHdHr => says_dts_hr || says_dtsx,
|
||||
Codec::Dts => says_dts,
|
||||
Codec::Lpcm => says_lpcm,
|
||||
// Unknown / other stream codec — don't second-guess the parser's hint.
|
||||
@@ -512,9 +524,9 @@ fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
}
|
||||
|
||||
// CLPI orphan streams: PIDs in /BDMV/CLIPINF/*.clpi ProgramInfo
|
||||
// that no MPLS playlist references. Empirical (2026-05-11): ~5%
|
||||
// of streams across the 11-disc corpus are CLPI-only — physically
|
||||
// on disc, not menu-reachable. Append them as Low-confidence
|
||||
// that no MPLS playlist references. Empirically a small fraction of
|
||||
// streams are CLPI-only — physically on disc, not menu-reachable.
|
||||
// Append them as Low-confidence
|
||||
// labels at the tail of each stream_type (next slot after the
|
||||
// highest existing stream_number).
|
||||
let _orphans_added = append_clpi_orphans(&mut labels, reader, udf);
|
||||
@@ -616,10 +628,13 @@ fn append_clpi_orphans(
|
||||
continue;
|
||||
}
|
||||
// Translate CLPI coding_type → label stream_type.
|
||||
// 0x90 = Presentation Graphics (PG subtitle). 0x91 =
|
||||
// Interactive Graphics (BD-J menu overlay), NOT a user-facing
|
||||
// subtitle — skip it, matching the MPLS path which drops IG.
|
||||
let stype = match s.coding_type {
|
||||
0x80..=0x86 | 0xA1 | 0xA2 => StreamLabelType::Audio,
|
||||
0x90 | 0x91 => StreamLabelType::Subtitle,
|
||||
_ => continue, // video / unknown — skip
|
||||
0x90 => StreamLabelType::Subtitle,
|
||||
_ => continue, // 0x91 IG / video / unknown — skip
|
||||
};
|
||||
// Same dedup logic as MPLS: normalize language, build codec
|
||||
// hint, check against existing label set.
|
||||
@@ -689,6 +704,26 @@ fn append_clpi_orphans(
|
||||
added
|
||||
}
|
||||
|
||||
/// Pick the winning parser result from `results` (built in PARSERS
|
||||
/// order): highest [`Confidence`] among non-empty results, with the
|
||||
/// earliest array position winning on a tie — matching `extract()`'s
|
||||
/// strict-`>` first-wins scan.
|
||||
///
|
||||
/// `Iterator::max_by_key` returns the LAST maximal element, so the key
|
||||
/// is `(confidence, Reverse(index))`: among equal-confidence entries the
|
||||
/// one with the smallest index has the largest `Reverse(index)` and is
|
||||
/// selected, i.e. first wins.
|
||||
fn select_result<'a>(
|
||||
results: &'a [(&'static str, ParseResult)],
|
||||
) -> Option<&'a (&'static str, ParseResult)> {
|
||||
results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, r))| !r.labels.is_empty())
|
||||
.max_by_key(|(idx, (_, r))| (r.confidence, std::cmp::Reverse(*idx)))
|
||||
.map(|(_, entry)| entry)
|
||||
}
|
||||
|
||||
/// Diagnostic introspection — returns the parser that matched, the
|
||||
/// labels it emitted, and the inventory of files under `/BDMV/JAR/*/`
|
||||
/// that the discriminators looked at. Intended for `freemkv-tools
|
||||
@@ -714,18 +749,8 @@ pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis {
|
||||
}
|
||||
|
||||
// Selection logic mirrors `extract`: highest confidence + non-empty,
|
||||
// array order tiebreaker.
|
||||
let chosen = all_results
|
||||
.iter()
|
||||
.filter(|(_, r)| !r.labels.is_empty())
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
// Cmp first by confidence (higher first), then position
|
||||
// (earlier first). max_by yields the maximum, so we
|
||||
// invert the index comparison.
|
||||
a.confidence
|
||||
.cmp(&b.confidence)
|
||||
.then(std::cmp::Ordering::Equal)
|
||||
});
|
||||
// with first-in-array-order winning on a confidence tie.
|
||||
let chosen = select_result(&all_results);
|
||||
|
||||
let (parser, confidence, mut labels) = match chosen {
|
||||
Some((name, r)) => (Some(*name), Some(r.confidence), r.labels.clone()),
|
||||
@@ -976,6 +1001,65 @@ mod registry_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn one_label() -> StreamLabel {
|
||||
StreamLabel {
|
||||
stream_number: 1,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: "eng".into(),
|
||||
name: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn result(conf: Confidence) -> ParseResult {
|
||||
ParseResult {
|
||||
labels: vec![one_label()],
|
||||
confidence: conf,
|
||||
}
|
||||
}
|
||||
|
||||
/// `select_result` must pick the highest-confidence non-empty result
|
||||
/// and, on a confidence tie, the FIRST in array order — matching
|
||||
/// `extract()`'s strict-`>` first-wins scan (regression for the old
|
||||
/// `analyze()` `max_by(...then(Equal))` no-op that picked the LAST).
|
||||
#[test]
|
||||
fn select_result_first_wins_on_tie() {
|
||||
// Two parsers, equal (Medium) confidence: the first must win.
|
||||
let results = vec![
|
||||
("alpha", result(Confidence::Medium)),
|
||||
("beta", result(Confidence::Medium)),
|
||||
];
|
||||
assert_eq!(select_result(&results).map(|(n, _)| *n), Some("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_result_highest_confidence_wins() {
|
||||
let results = vec![
|
||||
("low", result(Confidence::Low)),
|
||||
("high", result(Confidence::High)),
|
||||
("medium", result(Confidence::Medium)),
|
||||
];
|
||||
assert_eq!(select_result(&results).map(|(n, _)| *n), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_result_skips_empty_and_handles_none() {
|
||||
let empty = ParseResult {
|
||||
labels: Vec::new(),
|
||||
confidence: Confidence::High,
|
||||
};
|
||||
// High-confidence but empty must be skipped in favour of a
|
||||
// non-empty lower-confidence result.
|
||||
let results = vec![("empty", empty), ("real", result(Confidence::Low))];
|
||||
assert_eq!(select_result(&results).map(|(n, _)| *n), Some("real"));
|
||||
// No non-empty results → None.
|
||||
let none: Vec<(&'static str, ParseResult)> = Vec::new();
|
||||
assert!(select_result(&none).is_none());
|
||||
}
|
||||
|
||||
/// Per-parser sanity: every parser has both detect and parse
|
||||
/// hooked up. Catches accidental nullification (e.g. someone
|
||||
/// stubbing `parse` to always-None during a refactor).
|
||||
@@ -1339,6 +1423,44 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_consistent_dtsx_hint_on_dts_hd_ma() {
|
||||
// DTS:X rides a DTS-HD MA core just as Atmos rides TrueHD. A
|
||||
// correctly-authored "DTS:X" hint on a DtsHdMa stream is richer
|
||||
// than the spec codec yet consistent, so it's kept verbatim —
|
||||
// not discarded and regenerated to "DTS-HD Master Audio".
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::DtsHdMa,
|
||||
AudioChannels::Surround71,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "DTS:X", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "DTS:X");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtsx_hint_consistent_with_dts_hd_carriers() {
|
||||
use crate::disc::Codec;
|
||||
// The MED fix: a DTS:X hint must now be judged consistent with
|
||||
// its DTS-HD lossless carriers (previously it was rejected,
|
||||
// because says_dts_ma/says_dts_hr were both false for "DTS:X").
|
||||
assert!(codec_hint_consistent("DTS:X", &Codec::DtsHdMa));
|
||||
assert!(codec_hint_consistent("DTS-X 7.1", &Codec::DtsHdHr));
|
||||
assert!(codec_hint_consistent("dtsx", &Codec::DtsHdMa));
|
||||
// It still names the DTS family, so plain-DTS streams remain
|
||||
// consistent (family match) — never discarded.
|
||||
assert!(codec_hint_consistent("DTS:X", &Codec::Dts));
|
||||
// But a DTS:X hint on a non-DTS stream is a genuine mismatch.
|
||||
assert!(!codec_hint_consistent("DTS:X", &Codec::TrueHd));
|
||||
assert!(!codec_hint_consistent("DTS:X", &Codec::Ac3Plus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_normalizes_plain_consistent_hint_to_marketing() {
|
||||
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
// canonical "same physical stream" key; type+lang+codec round
|
||||
// out the rare case where two distinct logical streams happen
|
||||
// to share a PID across playlists with different metadata.
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
let mut seen: Vec<(StreamLabelType, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global 1-based counters keyed by StreamLabelType. Incremented
|
||||
// only when an entry survives dedup, so stream_numbers are dense
|
||||
@@ -101,8 +101,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
|
||||
let type_tag = type_tag(label_type);
|
||||
let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid);
|
||||
let key = (label_type, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
@@ -147,11 +146,12 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
fn has_mpls_extension(name: &str) -> bool {
|
||||
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
||||
// some lowercase; UDF filenames preserve case but we don't.
|
||||
let n = name.len();
|
||||
if n < 5 {
|
||||
return false;
|
||||
}
|
||||
name[n - 5..].eq_ignore_ascii_case(".mpls")
|
||||
//
|
||||
// UDF names are decoded via from_utf8_lossy, so a multi-byte
|
||||
// replacement char (EF BF BD) can straddle byte index n-5; a raw
|
||||
// byte slice there panics on a non-char-boundary. `ends_with` on a
|
||||
// lowercased copy is char-boundary-safe and still case-insensitive.
|
||||
name.len() >= 5 && name.to_ascii_lowercase().ends_with(".mpls")
|
||||
}
|
||||
|
||||
/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn codec_name(coding_type: u8) -> &'static str {
|
||||
0x82 => "DTS",
|
||||
0x83 => "TrueHD",
|
||||
0x84 => "AC-3+",
|
||||
0x85 => "DTS-HD",
|
||||
0x85 => "DTS-HD HR", // BD-ROM Part 3-1: 0x85 = DTS-HD High Resolution
|
||||
0x86 => "DTS-HD MA",
|
||||
0x90 => "PG",
|
||||
0x91 => "IG",
|
||||
@@ -287,17 +287,6 @@ fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntr
|
||||
out
|
||||
}
|
||||
|
||||
/// Dedup-tag for the label type. `u8` instead of `StreamLabelType`
|
||||
/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant
|
||||
/// in a way that we want to couple to (and `==` works fine for the
|
||||
/// linear `Vec::contains` lookup we do).
|
||||
fn type_tag(t: StreamLabelType) -> u8 {
|
||||
match t {
|
||||
StreamLabelType::Audio => 1,
|
||||
StreamLabelType::Subtitle => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -351,18 +340,32 @@ mod tests {
|
||||
/// don't have to synthesize valid MPLS bytes.
|
||||
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
let mut seen: Vec<(StreamLabelType, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global counters hoisted OUT of the playlist loop to match
|
||||
// production `parse()` (lines 77-78): stream_numbers are dense
|
||||
// per type across the whole disc, not reset per playlist.
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for playlist in playlists {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio,
|
||||
3 => StreamLabelType::Subtitle,
|
||||
_ => continue,
|
||||
};
|
||||
// Dedup BEFORE consuming a counter value, matching prod
|
||||
// parse() ordering so a deduped duplicate does not burn a
|
||||
// stream number.
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (label_type, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
@@ -373,19 +376,6 @@ mod tests {
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (
|
||||
type_tag(label_type),
|
||||
language.clone(),
|
||||
codec_hint.clone(),
|
||||
entry.pid,
|
||||
);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
@@ -474,6 +464,43 @@ mod tests {
|
||||
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
|
||||
langs.sort();
|
||||
assert_eq!(langs, vec!["deu", "eng", "fra"]);
|
||||
|
||||
// Stream numbers must be DENSE and GLOBAL across playlists, not
|
||||
// reset per playlist. eng (pl1) = 1, fra (pl1) = 2, the duplicate
|
||||
// eng in pl2 is deduped (no number consumed), and deu (pl2) = 3.
|
||||
// Regression guard for the per-playlist counter-reset divergence.
|
||||
let num = |lang: &str| {
|
||||
labels
|
||||
.iter()
|
||||
.find(|l| l.language == lang)
|
||||
.map(|l| l.stream_number)
|
||||
};
|
||||
assert_eq!(num("eng"), Some(1));
|
||||
assert_eq!(num("fra"), Some(2));
|
||||
assert_eq!(num("deu"), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_mpls_extension_handles_short_and_non_ascii_names() {
|
||||
// Short names: no panic, just false.
|
||||
assert!(!has_mpls_extension(""));
|
||||
assert!(!has_mpls_extension("a"));
|
||||
assert!(!has_mpls_extension(".mpl"));
|
||||
// Exact-length and longer valid suffixes, case-insensitive.
|
||||
assert!(has_mpls_extension("0.mpls"));
|
||||
assert!(has_mpls_extension("00000.MPLS"));
|
||||
assert!(has_mpls_extension("Movie.MpLs"));
|
||||
// Non-matching suffix.
|
||||
assert!(!has_mpls_extension("file.clpi"));
|
||||
// Multi-byte char near the tail must NOT panic on a byte-slice
|
||||
// boundary (from_utf8_lossy U+FFFD = EF BF BD is the real-disc
|
||||
// case). A name ending in such a char is simply not ".mpls".
|
||||
assert!(!has_mpls_extension("na\u{FFFD}me"));
|
||||
// And a name where a multi-byte char sits exactly at the n-5
|
||||
// boundary used by the old slice index.
|
||||
assert!(!has_mpls_extension("ab\u{FFFD}cd"));
|
||||
// A genuine .mpls preceded by a multi-byte char still matches.
|
||||
assert!(has_mpls_extension("f\u{FFFD}.mpls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -490,7 +517,7 @@ mod tests {
|
||||
(0x82, "DTS"),
|
||||
(0x83, "TrueHD"),
|
||||
(0x84, "AC-3+"),
|
||||
(0x85, "DTS-HD"),
|
||||
(0x85, "DTS-HD HR"),
|
||||
(0x86, "DTS-HD MA"),
|
||||
(0x90, "PG"),
|
||||
(0x91, "IG"),
|
||||
|
||||
+121
-17
@@ -27,24 +27,51 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
// Find the feature playlist — longest duration or name="Feature"
|
||||
let feature = find_feature_playlist(text)?;
|
||||
|
||||
let labels = labels_from_feature(&feature);
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// High confidence: paramount's playlists.xml is fully structured
|
||||
// and we extract every documented field.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
/// Build the stream labels from a single `<playlist .../>` feature
|
||||
/// element. Split out from `parse` so the per-type numbering and
|
||||
/// commentary/forced-index logic is unit-testable without a
|
||||
/// `SectorSource`/`UdfFs`.
|
||||
fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Parse audio streams
|
||||
if let Some(aud) = xml::attr(&feature, "aud") {
|
||||
let com_idx = xml::attr(&feature, "aud_com1_idx").and_then(|s| s.parse::<usize>().ok());
|
||||
if let Some(aud) = xml::attr(feature, "aud") {
|
||||
// aud_com1_idx is a trimmed, comma-separated list of CSV positions
|
||||
// (some authoring tools emit whitespace, and multiple commentary
|
||||
// tracks are possible) — symmetric with sub_com1_idx below.
|
||||
let com_indices: Vec<usize> = xml::attr(feature, "aud_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// stream_number must match apply_labels' monotonic 1-based
|
||||
// per-type counter, which increments once per *real* stream — so
|
||||
// it counts only non-empty slots, not the raw CSV index. The
|
||||
// commentary index comparison stays on the raw CSV index `i`,
|
||||
// since aud_com1_idx is positional against the original CSV.
|
||||
let mut audio_num: u16 = 0;
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let purpose = if com_idx == Some(i) {
|
||||
let purpose = if com_indices.contains(&i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
audio_num = audio_num.saturating_add(1);
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_number: audio_num,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
@@ -57,15 +84,18 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = xml::attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = xml::attr(&feature, "forced_sub")
|
||||
if let Some(sub) = xml::attr(feature, "sub") {
|
||||
let forced: Vec<bool> = xml::attr(feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let com_indices: Vec<usize> = xml::attr(&feature, "sub_com1_idx")
|
||||
let com_indices: Vec<usize> = xml::attr(feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
// As with audio: count only non-empty slots for stream_number,
|
||||
// but keep com/forced lookups on the raw CSV index `i`.
|
||||
let mut sub_num: u16 = 0;
|
||||
for (i, lang) in sub.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() {
|
||||
@@ -84,8 +114,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
sub_num = sub_num.saturating_add(1);
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_number: sub_num,
|
||||
stream_type: StreamLabelType::Subtitle,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
@@ -97,15 +128,11 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// High confidence: paramount's playlists.xml is fully structured
|
||||
// and we extract every documented field.
|
||||
Some(ParseResult::high(labels))
|
||||
labels
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
/// Find the feature playlist element (the one with the most non-empty
|
||||
/// audio slots).
|
||||
fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
@@ -121,9 +148,11 @@ fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams.
|
||||
// Otherwise pick the one with the most audio streams. Count only
|
||||
// non-empty slots so a malformed `aud=",,,,,"` can't outscore a
|
||||
// legitimate feature.
|
||||
if let Some(aud) = xml::attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
let count = aud.split(',').filter(|s| !s.trim().is_empty()).count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
best = Some(element.to_string());
|
||||
@@ -134,3 +163,78 @@ fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn audio(labels: &[StreamLabel]) -> Vec<&StreamLabel> {
|
||||
labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn subs(labels: &[StreamLabel]) -> Vec<&StreamLabel> {
|
||||
labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_middle_slot_does_not_inflate_stream_number() {
|
||||
// aud="eng,,fra": the empty middle slot is skipped, and the
|
||||
// second real stream (fra) must be numbered 2, matching
|
||||
// apply_labels' monotonic counter — not 3 (its raw CSV index).
|
||||
let feature = r#"<playlist name="Feature" aud="eng,,fra" />"#;
|
||||
let labels = labels_from_feature(feature);
|
||||
let a = audio(&labels);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(a[0].language, "eng");
|
||||
assert_eq!(a[0].stream_number, 1);
|
||||
assert_eq!(a[1].language, "fra");
|
||||
assert_eq!(a[1].stream_number, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aud_com1_idx_trimmed_and_multivalue() {
|
||||
// Whitespace around the index, and a multi-value list, must both
|
||||
// resolve. com index is positional against the raw CSV, so with
|
||||
// an empty slot at position 1, " 2 " marks the 'fra' track
|
||||
// (CSV index 2) as commentary.
|
||||
let feature = r#"<playlist aud="eng,,fra" aud_com1_idx=" 2 " />"#;
|
||||
let labels = labels_from_feature(feature);
|
||||
let a = audio(&labels);
|
||||
assert_eq!(a.len(), 2);
|
||||
assert_eq!(a[1].language, "fra");
|
||||
assert_eq!(a[1].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(a[0].purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_sub_aligns_with_raw_csv_index() {
|
||||
// sub="eng,eng,zho,ces" forced_sub="0,0,0,1": the forced flag is
|
||||
// positional on the raw CSV, so 'ces' (index 3) is forced; its
|
||||
// stream_number is its non-empty position (4 here, no gaps).
|
||||
let feature = r#"<playlist sub="eng,eng,zho,ces" forced_sub="0,0,0,1" />"#;
|
||||
let labels = labels_from_feature(feature);
|
||||
let s = subs(&labels);
|
||||
assert_eq!(s.len(), 4);
|
||||
assert_eq!(s[3].language, "ces");
|
||||
assert_eq!(s[3].qualifier, LabelQualifier::Forced);
|
||||
assert_eq!(s[3].stream_number, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_feature_skips_empty_audio_slot_playlist() {
|
||||
// A playlist of all-empty audio slots must not outscore a real
|
||||
// two-language feature.
|
||||
let xml = r#"
|
||||
<playlist name="Junk" aud=",,,,," />
|
||||
<playlist name="Movie" aud="eng,fra" />
|
||||
"#;
|
||||
let feature = find_feature_playlist(xml).expect("a feature is found");
|
||||
assert!(feature.contains(r#"name="Movie""#));
|
||||
}
|
||||
}
|
||||
|
||||
+109
-22
@@ -1,7 +1,7 @@
|
||||
//! Pixelogic — `bluray_project.bin`
|
||||
//!
|
||||
//! Binary file with embedded UTF-8 token strings in STN order per
|
||||
//! playlist section. Most common format (5/10 test discs).
|
||||
//! playlist section. A common Pixelogic layout.
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
@@ -11,10 +11,15 @@ use super::{
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
/// Sane upper bound on streams of one type within a single feature
|
||||
/// section. The BD STN table caps audio at 32; this generous ceiling
|
||||
/// stops a crafted blob with tens of thousands of stream tokens from
|
||||
/// overflowing the u16 STN counters (panic in debug, wrap-to-0 in
|
||||
/// release, which would misnumber subsequent labels).
|
||||
const MAX_STREAMS_PER_TYPE: u16 = 512;
|
||||
/// Known region tokens
|
||||
const REGIONS: &[&str] = &[
|
||||
"US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE",
|
||||
@@ -34,15 +39,16 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
// Tracked across all parse_token calls in this run: did any stream
|
||||
// hit an unrecognized token component (skip-unknown path)? If yes
|
||||
// we downgrade confidence to Medium — the labels are still valid
|
||||
// but the corpus surfaced something we don't catalogue.
|
||||
let saw_unknown = AtomicBool::new(false);
|
||||
// but the corpus surfaced something we don't catalogue. Parsing is
|
||||
// single-threaded and sequential, so a plain bool suffices.
|
||||
let mut saw_unknown = false;
|
||||
|
||||
let labels = assign_labels(&strings, &saw_unknown);
|
||||
let labels = assign_labels(&strings, &mut saw_unknown);
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let confidence = if saw_unknown.load(Ordering::Relaxed) {
|
||||
let confidence = if saw_unknown {
|
||||
Confidence::Medium
|
||||
} else {
|
||||
Confidence::High
|
||||
@@ -54,7 +60,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
/// `StreamLabel` per editorial token, numbered in STN order. Split out
|
||||
/// from `parse` so the section/numbering logic is unit-testable without
|
||||
/// a `SectorSource`/`UdfFs`.
|
||||
fn assign_labels(strings: &[String], saw_unknown: &AtomicBool) -> Vec<StreamLabel> {
|
||||
fn assign_labels(strings: &[String], saw_unknown: &mut bool) -> Vec<StreamLabel> {
|
||||
// The authoritative per-feature stream list lives in the `FPL_`
|
||||
// (FeaturePLaylist) section, in STN order. `SEG_*` entries are menu
|
||||
// segments (intros, logos, disclaimers, previews) that can also carry
|
||||
@@ -109,14 +115,25 @@ fn assign_labels(strings: &[String], saw_unknown: &AtomicBool) -> Vec<StreamLabe
|
||||
// left exactly as-is — the corpus snapshots show forced/commentary
|
||||
// subtitle tokens already align with STN without counting the
|
||||
// placeholders, and counting them regresses several discs.
|
||||
// Stop accumulating once both counters reach the sane cap — a
|
||||
// crafted blob can't drive them to u16 overflow.
|
||||
if audio_num >= MAX_STREAMS_PER_TYPE && sub_num >= MAX_STREAMS_PER_TYPE {
|
||||
break;
|
||||
}
|
||||
|
||||
if s.starts_with("Audio Stream") {
|
||||
audio_num += 1;
|
||||
if audio_num < MAX_STREAMS_PER_TYPE {
|
||||
audio_num += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(label) = parse_token_inner(s, Some(saw_unknown)) {
|
||||
if let Some(label) = parse_token_inner(s, Some(&mut *saw_unknown)) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
if audio_num >= MAX_STREAMS_PER_TYPE {
|
||||
continue;
|
||||
}
|
||||
audio_num += 1;
|
||||
labels.push(StreamLabel {
|
||||
stream_number: audio_num,
|
||||
@@ -124,6 +141,9 @@ fn assign_labels(strings: &[String], saw_unknown: &AtomicBool) -> Vec<StreamLabe
|
||||
});
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
if sub_num >= MAX_STREAMS_PER_TYPE {
|
||||
continue;
|
||||
}
|
||||
sub_num += 1;
|
||||
labels.push(StreamLabel {
|
||||
stream_number: sub_num,
|
||||
@@ -137,7 +157,7 @@ fn assign_labels(strings: &[String], saw_unknown: &AtomicBool) -> Vec<StreamLabe
|
||||
labels
|
||||
}
|
||||
|
||||
fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<StreamLabel> {
|
||||
fn parse_token_inner(s: &str, mut saw_unknown: Option<&mut bool>) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 {
|
||||
@@ -156,10 +176,18 @@ fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<Stream
|
||||
let mut is_subtitle = false;
|
||||
let mut is_audio = false;
|
||||
|
||||
for &part in &parts[1..] {
|
||||
if part.is_empty() {
|
||||
for &raw_part in &parts[1..] {
|
||||
if raw_part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Token components are spec-uppercase (codec IDs, ADES/ACOM/SDH,
|
||||
// region codes). vocab elsewhere is deliberately case-insensitive,
|
||||
// so normalize each component to uppercase before the gate to
|
||||
// avoid silently dropping a lowercase-authored token (which would
|
||||
// fall through to the unknown branch and, with no is_audio/
|
||||
// is_subtitle set, get the whole stream discarded below).
|
||||
let part_up = raw_part.to_ascii_uppercase();
|
||||
let part = part_up.as_str();
|
||||
if AUDIO_CODECS.contains(&part) {
|
||||
codec = vocab::codec(part).to_string();
|
||||
is_audio = true;
|
||||
@@ -182,10 +210,16 @@ fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<Stream
|
||||
} else if part == "STRI" || part == "TXT" {
|
||||
is_subtitle = true;
|
||||
} else if part == "FOR" {
|
||||
// `FOR` (forced) is a subtitle-domain qualifier. A token whose
|
||||
// only non-language component is FOR (e.g. `eng_FOR_`) would
|
||||
// otherwise classify as neither audio nor subtitle and be
|
||||
// dropped at the `!is_audio && !is_subtitle` guard below. Treat
|
||||
// a forced marker as a subtitle signal so the stream survives.
|
||||
qualifier = LabelQualifier::Forced;
|
||||
is_subtitle = true;
|
||||
} else if REGIONS.contains(&part) {
|
||||
variant = part.to_string();
|
||||
} else if part.starts_with("PGStream") {
|
||||
} else if part.starts_with("PGSTREAM") {
|
||||
is_subtitle = true;
|
||||
} else {
|
||||
// Unknown token component — skip this single part rather
|
||||
@@ -197,8 +231,8 @@ fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<Stream
|
||||
// but flag the parse as Medium-confidence so callers know
|
||||
// some data was elided.
|
||||
tracing::debug!(part = %part, "pixelogic: unrecognized token component, skipping");
|
||||
if let Some(flag) = saw_unknown {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
if let Some(flag) = saw_unknown.as_deref_mut() {
|
||||
*flag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +241,14 @@ fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<Stream
|
||||
return None;
|
||||
}
|
||||
|
||||
let stream_type = if is_subtitle {
|
||||
// Tie-break for tokens that signal both domains (e.g. `eng_MLP_SDH_`
|
||||
// sets is_audio via the codec and is_subtitle via SDH). An audio
|
||||
// codec hint is the stronger, audio-domain signal, so prefer Audio
|
||||
// when one is present (keeps the parsed codec_hint instead of
|
||||
// discarding it); otherwise file as Subtitle. Pure-subtitle and
|
||||
// pure-audio tokens are unaffected.
|
||||
let has_audio_codec = is_audio && !codec.is_empty();
|
||||
let stream_type = if is_subtitle && !has_audio_codec {
|
||||
StreamLabelType::Subtitle
|
||||
} else {
|
||||
StreamLabelType::Audio
|
||||
@@ -294,6 +335,52 @@ mod tests {
|
||||
assert!(parse_token_inner("ENG_MLP_", None).is_none()); // uppercase not accepted as ISO 639-2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_dual_type_with_codec_prefers_audio() {
|
||||
// `eng_MLP_SDH_` sets the audio codec (MLP) and the subtitle SDH
|
||||
// qualifier. Policy: a codec hint wins -> Audio, and codec_hint is
|
||||
// preserved rather than discarded.
|
||||
let l = parse_token_inner("eng_MLP_SDH_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_solo_forced_is_subtitle() {
|
||||
// A token whose only non-language component is FOR must survive as
|
||||
// a forced subtitle rather than being dropped.
|
||||
let l = parse_token_inner("eng_FOR_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_components_are_case_insensitive() {
|
||||
// Regression for the case-sensitive gate: a lowercase codec/
|
||||
// qualifier component must classify identically to uppercase
|
||||
// rather than falling through to the unknown branch and getting
|
||||
// the whole stream dropped. The ISO 639-2 lang prefix is still
|
||||
// required lowercase.
|
||||
let l = parse_token_inner("eng_mlp_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
|
||||
let l = parse_token_inner("eng_ac3_acom_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(l.codec_hint, "Dolby Digital");
|
||||
|
||||
let l = parse_token_inner("eng_sdh_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
|
||||
// Mixed-case region token still recognized as a variant.
|
||||
let l = parse_token_inner("eng_MLP_us_", None).unwrap();
|
||||
assert_eq!(l.variant, "US");
|
||||
}
|
||||
|
||||
fn strs(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
@@ -305,7 +392,7 @@ mod tests {
|
||||
// `eng_ACOM_` commentary at STN slot 4. The commentary must land on
|
||||
// audio #4, not collapse onto #1 (which would tag the main feature
|
||||
// track as commentary).
|
||||
let flag = AtomicBool::new(false);
|
||||
let mut flag = false;
|
||||
let tokens = strs(&[
|
||||
"FPL_MainFeature",
|
||||
"Audio Stream 1",
|
||||
@@ -313,7 +400,7 @@ mod tests {
|
||||
"Audio Stream 3",
|
||||
"eng_ACOM_",
|
||||
]);
|
||||
let labels = assign_labels(&tokens, &flag);
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
let audio: Vec<_> = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
@@ -330,7 +417,7 @@ mod tests {
|
||||
// token, but the real playlist is `FPL_MainFeature`. When an FPL_
|
||||
// section exists, the SEG_ one must be ignored as an anchor — so we
|
||||
// number from the FPL playlist, putting the commentary at slot 2.
|
||||
let flag = AtomicBool::new(false);
|
||||
let mut flag = false;
|
||||
let tokens = strs(&[
|
||||
"SEG_MainFeature",
|
||||
"eng_ACOM_", // stray token in the menu segment — must be ignored
|
||||
@@ -338,7 +425,7 @@ mod tests {
|
||||
"Audio Stream 1",
|
||||
"eng_ACOM_",
|
||||
]);
|
||||
let labels = assign_labels(&tokens, &flag);
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
let audio: Vec<_> = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
@@ -351,9 +438,9 @@ mod tests {
|
||||
#[test]
|
||||
fn assign_labels_falls_back_to_seg_without_fpl() {
|
||||
// Discs with no FPL_ playlist still anchor on SEG_MainFeature.
|
||||
let flag = AtomicBool::new(false);
|
||||
let mut flag = false;
|
||||
let tokens = strs(&["SEG_MainFeature", "eng_MLP_", "spa_AC3_"]);
|
||||
let labels = assign_labels(&tokens, &flag);
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
let audio: Vec<_> = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
|
||||
+19
-8
@@ -1,13 +1,14 @@
|
||||
//! Text-extraction helpers used by parsers that scan binary blobs for
|
||||
//! embedded label strings.
|
||||
//!
|
||||
//! Promoted from two near-duplicate implementations:
|
||||
//! - `pixelogic::extract_strings` (`bluray_project.bin`, min_len=4)
|
||||
//! - `dbp::extract_printable` (`.class` files in jars, min_len=5)
|
||||
//! Promoted from a byte-scanning helper (`bluray_project.bin`,
|
||||
//! min_len=4). Single implementation, threshold passed in.
|
||||
//!
|
||||
//! Single implementation, threshold passed in. Callers that have a
|
||||
//! more structured parse path (e.g. `class_reader` for .class) should
|
||||
//! prefer that — this helper is for genuinely unstructured input.
|
||||
//! `dbp` no longer uses a byte-scanning helper — it iterates
|
||||
//! `class_reader::CpInfo::Utf8` constant-pool entries directly. Callers
|
||||
//! that have a more structured parse path (e.g. `class_reader` for
|
||||
//! `.class`) should prefer that; this helper is for genuinely
|
||||
//! unstructured input.
|
||||
|
||||
/// Walk `data`, emit every maximal run of printable-ASCII bytes
|
||||
/// (`0x20..=0x7E`) whose length is at least `min_len`.
|
||||
@@ -21,13 +22,13 @@ pub fn extract_ascii_strings(data: &[u8], min_len: usize) -> Vec<String> {
|
||||
for &b in data {
|
||||
if (0x20..=0x7E).contains(&b) {
|
||||
current.push(b as char);
|
||||
} else if current.len() >= min_len {
|
||||
} else if !current.is_empty() && current.len() >= min_len {
|
||||
out.push(std::mem::take(&mut current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() >= min_len {
|
||||
if !current.is_empty() && current.len() >= min_len {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
@@ -86,4 +87,14 @@ mod tests {
|
||||
let got = extract_ascii_strings(b"a\0b", 0);
|
||||
assert_eq!(got, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_len_zero_skips_empty_runs_on_consecutive_separators() {
|
||||
// Consecutive separators must NOT emit empty strings even at
|
||||
// min_len=0 — an empty string is not a "run of printable bytes".
|
||||
let got = extract_ascii_strings(b"\0\0abc", 0);
|
||||
assert_eq!(got, vec!["abc"]);
|
||||
let got = extract_ascii_strings(b"ab\0\0\0cd\0\0", 0);
|
||||
assert_eq!(got, vec!["ab", "cd"]);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-35
@@ -32,16 +32,19 @@ use super::{LabelPurpose, LabelQualifier};
|
||||
/// Map a codec identifier found in label data to its display name.
|
||||
///
|
||||
/// These are well-known codec identifiers used across multiple BD-J
|
||||
/// authoring tools. Unknown codes pass through unchanged so callers
|
||||
/// can still surface vendor-specific tokens we haven't catalogued.
|
||||
/// authoring tools. Matching is case-insensitive (on-disc tokens vary:
|
||||
/// `ATMOS`, `Atmos`, `atmos`). Unknown codes pass through unchanged (in
|
||||
/// their original casing) so callers can still surface vendor-specific
|
||||
/// tokens we haven't catalogued.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
match code.to_ascii_uppercase().as_str() {
|
||||
"MLP" => "TrueHD",
|
||||
"AC3" | "AC" => "Dolby Digital",
|
||||
"DTS" => "DTS",
|
||||
"DDL" => "Dolby Digital Plus",
|
||||
"WAV" => "PCM",
|
||||
"atmos" => "Dolby Atmos",
|
||||
"ATMOS" => "Dolby Atmos",
|
||||
// "DTS" is recognized but has no distinct display alias — return
|
||||
// the original token rather than a re-cased copy.
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
@@ -54,10 +57,9 @@ pub fn codec(code: &str) -> &str {
|
||||
/// `variant` is the regional dialect as a human-readable English word
|
||||
/// (`"Brazilian"`, `"Castilian"`, `"Canadian"`, `"Simplified"`, ...)
|
||||
/// or `""` when the input names just a bare language without
|
||||
/// dialect ("Spanish" → variant=""). The variant matches the
|
||||
/// convention pixelogic / ctrm / criterion already use for their
|
||||
/// `StreamLabel::variant` field: a short display token the UI can
|
||||
/// surface verbatim.
|
||||
/// dialect ("Spanish" → variant=""). It is a short display token
|
||||
/// suitable for the [`StreamLabel::variant`](super::StreamLabel) field,
|
||||
/// to be surfaced verbatim by the UI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LangInfo {
|
||||
pub code: &'static str,
|
||||
@@ -70,10 +72,14 @@ pub struct LangInfo {
|
||||
/// Handles both bare English names ("English", "Spanish") and the
|
||||
/// multi-word vendor variants we've seen in the corpus ("Brazilian
|
||||
/// Portuguese", "Castilian Spanish", "Canadian French"). Match is
|
||||
/// case-insensitive; longer compound phrases win over their bare
|
||||
/// counterparts (so "Brazilian Portuguese" returns
|
||||
/// `LangInfo { code: "por", variant: "Brazilian" }`, not consumed by
|
||||
/// the bare "Portuguese" entry).
|
||||
/// case-insensitive. Compound phrases are scanned BEFORE bare names, so
|
||||
/// "Brazilian Portuguese" returns
|
||||
/// `LangInfo { code: "por", variant: "Brazilian" }` rather than being
|
||||
/// consumed by the bare "Portuguese" entry. Within `COMPOUND_LANGS` the
|
||||
/// scan is positional (first `contains` hit wins), so that table MUST be
|
||||
/// maintained longest-first — a longer phrase must precede any shorter
|
||||
/// phrase it contains (e.g. "latin american spanish" before
|
||||
/// "latin spanish").
|
||||
///
|
||||
/// Bare-name matches return `variant: ""`.
|
||||
///
|
||||
@@ -81,15 +87,16 @@ pub struct LangInfo {
|
||||
/// fall back to MPLS spec codes, pass through raw, or drop the stream.
|
||||
/// Never guesses.
|
||||
///
|
||||
/// Why the variant: the prior `lang() -> Option<&str>` shape silently
|
||||
/// dropped regional dialect info. "Brazilian Portuguese 5.1" became
|
||||
/// `language="por", variant=""` — UI displayed plain "Portuguese"
|
||||
/// even though the disc had explicitly labeled this stream Brazilian.
|
||||
/// Capturing the variant here parallels how pixelogic and ctrm
|
||||
/// populate `StreamLabel::variant` from their own region tables.
|
||||
/// Why the variant: returning only the ISO code would silently drop
|
||||
/// regional dialect info — "Brazilian Portuguese 5.1" would become
|
||||
/// `language="por", variant=""` and the UI would display plain
|
||||
/// "Portuguese" even though the disc explicitly labeled the stream
|
||||
/// Brazilian. Returning the variant lets callers populate
|
||||
/// [`StreamLabel::variant`](super::StreamLabel) with the dialect.
|
||||
pub fn lang(text: &str) -> Option<LangInfo> {
|
||||
let lower = text.to_lowercase();
|
||||
// Multi-word compounds first — longest-match wins.
|
||||
// Multi-word compounds first. Scan is positional (first hit wins),
|
||||
// so COMPOUND_LANGS MUST stay ordered longest-first.
|
||||
for (needle, code, variant) in COMPOUND_LANGS {
|
||||
if lower.contains(needle) {
|
||||
return Some(LangInfo { code, variant });
|
||||
@@ -250,22 +257,26 @@ fn has_word(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let bytes = haystack.as_bytes();
|
||||
let nb = needle.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + nb.len() <= bytes.len() {
|
||||
if &bytes[i..i + nb.len()] == nb {
|
||||
let before = if i == 0 { None } else { Some(bytes[i - 1]) };
|
||||
let after = bytes.get(i + nb.len()).copied();
|
||||
let bound = |c: Option<u8>| match c {
|
||||
None => true,
|
||||
Some(b) => !b.is_ascii_alphanumeric(),
|
||||
};
|
||||
if bound(before) && bound(after) {
|
||||
return true;
|
||||
}
|
||||
// Boundary check is char-aware (not byte-level): a non-ASCII letter
|
||||
// adjacent to the match (e.g. an accented or CJK char, which is
|
||||
// multiple UTF-8 bytes) is alphanumeric and so is NOT a boundary,
|
||||
// preventing false positives like "sdh" inside "cafésch". Needles
|
||||
// are ASCII tokens, so a byte-offset match aligns with char
|
||||
// boundaries in `haystack`.
|
||||
for (idx, _) in haystack.match_indices(needle) {
|
||||
// Char immediately before the match.
|
||||
let before_is_alnum = haystack[..idx]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(char::is_alphanumeric);
|
||||
// Char immediately after the match.
|
||||
let after_is_alnum = haystack[idx + needle.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_alphanumeric);
|
||||
if !before_is_alnum && !after_is_alnum {
|
||||
return true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -283,12 +294,26 @@ mod tests {
|
||||
assert_eq!(codec("AC"), "Dolby Digital");
|
||||
assert_eq!(codec("DDL"), "Dolby Digital Plus");
|
||||
assert_eq!(codec("atmos"), "Dolby Atmos");
|
||||
assert_eq!(codec("WAV"), "PCM");
|
||||
assert_eq!(codec("DTS"), "DTS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_case_insensitive() {
|
||||
// On-disc casing varies; all forms must canonicalize.
|
||||
assert_eq!(codec("ATMOS"), "Dolby Atmos");
|
||||
assert_eq!(codec("Atmos"), "Dolby Atmos");
|
||||
assert_eq!(codec("atmos"), "Dolby Atmos");
|
||||
assert_eq!(codec("mlp"), "TrueHD");
|
||||
assert_eq!(codec("ac3"), "Dolby Digital");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_unknown_passes_through() {
|
||||
assert_eq!(codec("FX9"), "FX9");
|
||||
assert_eq!(codec(""), "");
|
||||
// Unknown tokens keep their original casing.
|
||||
assert_eq!(codec("Vendor_X"), "Vendor_X");
|
||||
}
|
||||
|
||||
fn li(code: &'static str, variant: &'static str) -> LangInfo {
|
||||
@@ -390,6 +415,26 @@ mod tests {
|
||||
assert_eq!(purpose(""), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_ime() {
|
||||
assert_eq!(purpose("IME"), LabelPurpose::Ime);
|
||||
assert_eq!(purpose("English ime"), LabelPurpose::Ime);
|
||||
// Word-boundary: "ime" inside "time" must not match.
|
||||
assert_eq!(purpose("Showtime audio"), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_treats_non_ascii_letter_as_a_letter_boundary() {
|
||||
// A non-ASCII (multi-byte) letter glued to the needle is NOT a
|
||||
// word boundary, so the needle must not match there.
|
||||
assert!(!has_word("cafésdh", "sdh")); // 'é' precedes "sdh"
|
||||
assert!(!has_word("日本sdh", "sdh"));
|
||||
// But a real boundary (space / punctuation / non-letter) matches.
|
||||
assert!(has_word("café sdh", "sdh"));
|
||||
assert!(has_word("日本 sdh", "sdh"));
|
||||
assert!(has_word("sdh", "sdh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_sdh() {
|
||||
assert_eq!(qualifier("English SDH"), LabelQualifier::Sdh);
|
||||
|
||||
+51
-17
@@ -37,6 +37,19 @@ pub fn attr(element: &str, name: &str) -> Option<String> {
|
||||
let name_bytes = name_lower.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + name_bytes.len() < bytes.len() {
|
||||
// Skip over a quoted attribute value entirely so a name token
|
||||
// embedded inside another attribute's value (e.g.
|
||||
// `y="name='inner'"`) is never matched as a real attribute.
|
||||
if bytes[i] == b'"' || bytes[i] == b'\'' {
|
||||
let q = bytes[i];
|
||||
i += 1;
|
||||
while i < bytes.len() && bytes[i] != q {
|
||||
i += 1;
|
||||
}
|
||||
// Step past the closing quote (or to EOF).
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Find the next position where `name=` could start. We need
|
||||
// a word boundary before the name (whitespace or `<` or `:`).
|
||||
if i > 0 && is_name_char(bytes[i - 1]) {
|
||||
@@ -96,17 +109,13 @@ pub fn attr(element: &str, name: &str) -> Option<String> {
|
||||
/// handled — the first close encountered wins (this matches the
|
||||
/// prior behavior in criterion.rs).
|
||||
pub fn text(xml: &str, tag: &str) -> Option<String> {
|
||||
let (open_end, body_start) = find_open_tag(xml, tag, 0)?;
|
||||
// Self-closing — already consumed in find_open_tag if `/>`.
|
||||
if open_end == body_start {
|
||||
// Means find_open_tag returned the same offset twice for
|
||||
// self-closing form. (Not currently the case in our impl,
|
||||
// but defensive.)
|
||||
return Some(String::new());
|
||||
}
|
||||
// For self-closing tags, body_start is past `/>` and we have no
|
||||
// content. Detect by checking the char at body_start - 1 was `/`.
|
||||
if body_start >= 2 && &xml[body_start - 2..body_start] == "/>" {
|
||||
let (_open_end, body_start) = find_open_tag(xml, tag, 0)?;
|
||||
// For self-closing tags, body_start is past `/>` and there is no
|
||||
// content. Detect with a *byte* comparison: slicing `&xml[..]` two
|
||||
// bytes back can land inside a multi-byte UTF-8 char and panic
|
||||
// (untrusted on-disc XML), but indexing the byte slice never does.
|
||||
let b = xml.as_bytes();
|
||||
if body_start >= 2 && b[body_start - 2] == b'/' && b[body_start - 1] == b'>' {
|
||||
return Some(String::new());
|
||||
}
|
||||
// Find the matching close tag. Case-insensitive + namespace-aware.
|
||||
@@ -121,9 +130,9 @@ pub fn text(xml: &str, tag: &str) -> Option<String> {
|
||||
/// iterating over repeated elements like `<playlist>` blocks in
|
||||
/// `paramount`.
|
||||
///
|
||||
/// Self-closing elements return the same offset for body_end as the
|
||||
/// element_end (i.e. `element_end - element_start` includes only the
|
||||
/// `<tag .../>` text).
|
||||
/// For self-closing elements, `element_end` points just past `/>` and
|
||||
/// there is no separate body range (`element_end - element_start`
|
||||
/// spans only the `<tag .../>` text).
|
||||
pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> {
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = from;
|
||||
@@ -190,8 +199,9 @@ pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)>
|
||||
/// The character after the tag name must not be a name-continuation
|
||||
/// (so `<player>` doesn't match `<play>`).
|
||||
fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool {
|
||||
let tag_lower = tag.to_ascii_lowercase();
|
||||
let tag_bytes = tag_lower.as_bytes();
|
||||
// Compare case-insensitively without allocating a lowercased copy
|
||||
// of `tag` on every call (hot path: once per `<`/`</`).
|
||||
let tag_bytes = tag.as_bytes();
|
||||
// Skip optional `prefix:` (one or more name chars + `:`).
|
||||
let mut name_start = start;
|
||||
let mut scan = start;
|
||||
@@ -204,7 +214,7 @@ fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool {
|
||||
if name_start + tag_bytes.len() > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
if !slice_eq_ignore_case(&bytes[name_start..name_start + tag_bytes.len()], tag_bytes) {
|
||||
if !bytes[name_start..name_start + tag_bytes.len()].eq_ignore_ascii_case(tag_bytes) {
|
||||
return false;
|
||||
}
|
||||
// Boundary: char after the tag name must be `>`, `/`, whitespace.
|
||||
@@ -449,4 +459,28 @@ mod tests {
|
||||
let (s, e) = find_element(xml, "item", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<ns:item id="1" />"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_multibyte_before_self_close_does_not_panic() {
|
||||
// A multi-byte UTF-8 char ending right before the `/>` used to
|
||||
// panic on a non-char-boundary str slice in `text()`. The
|
||||
// byte-level self-closing check must handle it cleanly.
|
||||
// 'é' (0xC3 0xA9) directly precedes the `/>`.
|
||||
assert_eq!(text("<x>é</x>", "x"), Some("é".into()));
|
||||
// Self-closing form with a multi-byte char in an attr value.
|
||||
assert_eq!(text(r#"<x a="é"/>"#, "x"), Some("".into()));
|
||||
assert_eq!(text("<x>日本語</x>", "x"), Some("日本語".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_not_matched_inside_quoted_value() {
|
||||
// `name` appears only inside another attribute's quoted value;
|
||||
// it must NOT be returned as a real attribute.
|
||||
assert_eq!(attr(r#"<x y="name='inner'"/>"#, "name"), None);
|
||||
// A real `name` attribute after a decoy value still resolves.
|
||||
assert_eq!(
|
||||
attr(r#"<x y="name='inner'" name="real"/>"#, "name"),
|
||||
Some("real".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user