//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_.xml`). //! //! Every commercial Blu-ray carries a disc-library metadata directory //! with one XML file per shipped language. The schema is the Blu-ray //! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`), //! conventionally prefixed `di:`. Fields commonly present: //! //! - `` or `` — the title string. Vendor practice //! varies (Paramount discs tend to use ``). //! - `` — optional synopsis (often absent on retail //! discs; common on box sets and special editions). //! - `` / `` (or ``) — //! set position for multi-disc releases. //! //! 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. 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. //! Extraction is best-effort — a malformed file is treated as "no //! metadata" (returns `None` from the helper), and the caller can //! still get metadata from sibling-language XML files. use super::xml; use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; /// Upper bound on the size of a single `bdmt_.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. /// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the /// `bdmt_.xml` filename. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] pub struct DiscMetadata { /// Localized titles, keyed by 3-char ISO 639-2 lang code /// (e.g. "eng" → "Aurora Drift") pub titles: BTreeMap, /// First-line / short description, per lang pub descriptions: BTreeMap, /// Disc N of M for box sets (None if not a box set) pub disc_number: Option<(u32, u32)>, } /// True if `/BDMV/META/DL/` exists and contains at least one /// `bdmt_*.xml` file. pub fn detect(udf: &UdfFs) -> bool { let Some(dir) = udf.find_dir("/BDMV/META/DL") else { return false; }; dir.entries .iter() .any(|e| !e.is_dir && is_bdmt_filename(&e.name)) } /// Read every `bdmt_.xml` under `/BDMV/META/DL/` and return the /// aggregated [`DiscMetadata`]. Returns `None` if no titles could be /// extracted from any file. pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let dir = udf.find_dir("/BDMV/META/DL")?; let mut out = DiscMetadata::default(); for entry in &dir.entries { if entry.is_dir { continue; } 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; }; let Ok(text) = std::str::from_utf8(&bytes) else { continue; }; let Some((title, description, disc_set)) = parse_bdmt_xml(text) else { continue; }; out.titles.insert(lang.clone(), title); if let Some(desc) = description { out.descriptions.insert(lang.clone(), desc); } // Disc-set position is disc-global; first one we successfully // read wins. (All bdmt_*.xml on a given disc carry the same // value in practice.) if out.disc_number.is_none() { if let Some(ds) = disc_set { out.disc_number = Some(ds); } } } if out.titles.is_empty() { None } else { Some(out) } } /// Gate a `bdmt_.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_.xml` convention with a /// 3-character ISO 639-2 lang code segment. Case-insensitive. fn is_bdmt_filename(name: &str) -> bool { lang_code_from_filename(name).is_some() } /// Extract the 3-char language code from a `bdmt_.xml` filename. /// Returns `None` if the filename doesn't match. Lang code is /// lowercased so callers always see e.g. `"eng"` not `"ENG"`. fn lang_code_from_filename(name: &str) -> Option { let lower = name.to_ascii_lowercase(); let stem = lower.strip_suffix(".xml")?; let lang = stem.strip_prefix("bdmt_")?; // ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps // us from picking up unrelated `bdmt_foo.xml` siblings. if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) { return None; } Some(lang.to_string()) } /// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`. /// Aliased so the function signature isn't a clippy::type-complexity offender. pub(crate) type BdmtFields = (String, Option, Option<(u32, u32)>); /// Parse one `bdmt_.xml` document and return /// `(title, description?, disc_set?)`. Returns `None` if no title /// could be located — the caller treats this as "skip this file". /// /// Title-element preference: `` → `` → /// `/` (first match wins, per the /// authoring-tool conventions documented at the module level). pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option { 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)); let disc_set = extract_disc_set(xml_text); Some((title, description, disc_set)) } /// Reject candidate description strings that are themselves XML /// fragments — observed on a captured disc, where /// `` contained `` child /// elements and no actual prose. Surfacing that raw to the JSON /// output is worse than dropping the field entirely. fn looks_like_xml(s: &str) -> bool { let t = s.trim_start(); t.starts_with('<') } /// Try title-bearing element variants in priority order. The `xml` /// helpers are case- and namespace-insensitive, so callers pass the /// bare local name (no `di:` prefix). fn extract_title(xml_text: &str) -> Option { // Order matches the module-level convention: first // (Paramount-style), then , 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) { if !s.is_empty() { return Some(s); } } } // tableOfContents/titleName: search inside the toc block so we // don't accidentally pick a stray from elsewhere. 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") { if !t.is_empty() { return Some(t); } } } None } /// Extract `(discNumber, numSets)` if both are present and parse as /// `u32`. Accepts either `` or `` for /// the denominator (both forms appear in the wild). fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> { let n = xml::text(xml_text, "discNumber")? .trim() .parse::() .ok()?; let total = xml::text(xml_text, "numSets") .or_else(|| xml::text(xml_text, "numberOfSets"))? .trim() .parse::() .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)) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_simple_title() { // Minimal Paramount-style document: as the title // carrier inside a root. let xml = r#" Aurora Drift "#; 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); } #[test] fn extract_title_element_variant() { // is the alternate carrier; should be picked up // when is absent. let xml = r#" Echo Chamber A film about machines. "#; let (title, desc, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Echo Chamber"); assert_eq!(desc.as_deref(), Some("A film about machines.")); } #[test] fn extract_title_from_table_of_contents_fallback() { // Some authoring tools nest the title under tableOfContents. // No or at top level → fall back to // titleName inside tableOfContents. let xml = r#" Feelings Two "#; 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#" X 5 2 "#; assert_eq!(parse_bdmt_xml(over).unwrap().2, None); let zero_n = r#" X 0 5 "#; assert_eq!(parse_bdmt_xml(zero_n).unwrap().2, None); let zero_total = r#" X 1 0 "#; assert_eq!(parse_bdmt_xml(zero_total).unwrap().2, None); // A valid pair still passes. let ok = r#" X 2 3 "#; assert_eq!(parse_bdmt_xml(ok).unwrap().2, Some((2, 3))); } #[test] fn extract_box_set_position() { let xml = r#" Box Set Disc 2 2 5 "#; let (_, _, set) = parse_bdmt_xml(xml).unwrap(); assert_eq!(set, Some((2, 5))); } #[test] fn extract_box_set_position_alternate_total_tag() { // is an alternate spelling we've seen. let xml = r#" X 3 6 "#; let (_, _, set) = parse_bdmt_xml(xml).unwrap(); assert_eq!(set, Some((3, 6))); } #[test] fn extract_box_set_requires_both_fields() { // discNumber alone (no total) yields None — we don't fabricate // a denominator. let xml = r#" X 1 "#; let (_, _, set) = parse_bdmt_xml(xml).unwrap(); assert_eq!(set, None); } #[test] fn multiple_languages_keyed_correctly() { // Simulate driving parse_bdmt_xml from two synthetic XML // blobs and aggregating into DiscMetadata the same way parse() // would. This exercises the BTreeMap key handling without // needing a UdfFs. let eng_xml = r#" Aurora Drift "#; let fra_xml = r#" Aurora Drift (Partie Deux) Suite du film fictif. "#; let mut meta = DiscMetadata::default(); for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] { 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); } if meta.disc_number.is_none() { if let Some(d) = ds { meta.disc_number = Some(d); } } } assert_eq!( meta.titles.get("eng").map(String::as_str), Some("Aurora Drift") ); assert_eq!( meta.titles.get("fra").map(String::as_str), Some("Aurora Drift (Partie Deux)") ); assert!(!meta.descriptions.contains_key("eng")); assert_eq!( meta.descriptions.get("fra").map(String::as_str), Some("Suite du film fictif.") ); assert_eq!(meta.disc_number, None); } #[test] fn malformed_xml_returns_none() { // Random gibberish has no recognizable title element. We // document the contract: parse_bdmt_xml returns None, and // parse() (the caller) skips the file. Aggregating across // zero files leaves DiscMetadata::default() — which parse() // 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(bad).is_none()); // Half-open tag, no body, no close: also yields no title. let truncated = ""; assert!(parse_bdmt_xml(truncated).is_none()); } #[test] fn description_with_only_child_xml_is_dropped() { // Real-world bug: contained only // 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#" Skyline Run "#; let (title, description, _) = parse_bdmt_xml(xml).expect("title is present so parse must succeed"); assert_eq!(title, "Skyline Run"); assert!( description.is_none(), "description containing only XML children must be dropped, got {description:?}" ); } #[test] fn description_with_plain_text_passes_through() { // The legitimate case still works: a description with actual // prose survives the looks_like_xml filter. let xml = r#" Some Movie An epic tale of one man's quest for tea. "#; 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.") ); } #[test] fn whitespace_in_title_is_trimmed() { let xml = r#" Aurora Drift "#; let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Aurora Drift"); } #[test] fn lang_code_extraction() { assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into())); assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into())); assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into())); // Non-matching cases: assert_eq!(lang_code_from_filename("bdmt_.xml"), None); assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None); assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None); assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None); assert_eq!(lang_code_from_filename("foo.xml"), None); } // ── Additional hardening tests ───────────────────────────────────────── /// Spec reference: BDA disc-library metadata schema, §3.3.2 — `` /// takes priority over `` as the title carrier. /// Mutation: swap `di:name` to `di:other` → test goes red because title is None. #[test] fn di_name_priority_over_di_title() { // When BOTH di:name and di:title are present, di:name wins. let xml = r#" Primary Title Fallback Title "#; let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Primary Title"); } /// Spec reference: BDA disc-library metadata §3.3.2 — `` /// with nested `` is a vendor-specific variant. /// Mutation: rename `titleName` → `movieName` → test goes red (None). #[test] fn di_name_wins_over_table_of_contents_title_name() { // di:name exists — tableOfContents/titleName must NOT override it. let xml = r#" Winner Loser "#; let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Winner"); } /// Spec reference: BDA §3.3.2 — an empty `` element must be /// treated as absent, falling through to the next candidate. /// Mutation: change `` to `X` → red. #[test] fn empty_di_name_falls_through_to_di_title() { let xml = r#" Non-Empty Title "#; let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Non-Empty Title"); } /// Mutation: remove the `!s.is_empty()` filter → empty descriptions /// come through as Some(""). #[test] fn empty_description_element_filtered_out() { let xml = r#" Film "#; let (_, description, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(description, None); } /// Disc N of N (e.g. 3 of 3) is valid — not an off-by-one error. /// Mutation: change `n > total` to `n >= total` → last disc of set is None. #[test] fn disc_set_allows_last_disc_equal_total() { let xml = r#" Film 3 3 "#; let (_, _, set) = parse_bdmt_xml(xml).unwrap(); assert_eq!(set, Some((3, 3))); } /// When `` has non-numeric text, disc_number must be None. /// Mutation: remove the `.parse::().ok()?` guard → panics or wrong value. #[test] fn disc_set_non_numeric_disc_number_yields_none() { let xml = r#" Film one 5 "#; let (_, _, set) = parse_bdmt_xml(xml).unwrap(); assert_eq!(set, None); } /// Whitespace-only title element must be treated as empty (trimmed → ""). /// Spec: xml::text trims; an all-whitespace element produces "" after trim, /// which the title-extraction logic should skip. /// Mutation: remove the `!s.is_empty()` guard in extract_title → /// whitespace-only di:name would be returned as the title. #[test] fn whitespace_only_di_name_falls_through() { let xml = r#" Real Title "#; let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Real Title"); } }