From 087297c9d58ae28169d5bba76026850186dbe357 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:48:55 -0700 Subject: [PATCH 1/4] labels: decode bdmt entities, drop PiP PG labels, cap mpls floor, close parser test gaps --- src/labels/bdj_feature.rs | 135 +++++++++++++--- src/labels/bdmt.rs | 107 +++++++++++-- src/labels/class_reader.rs | 18 +++ src/labels/clpi_audit.rs | 69 ++++++++ src/labels/criterion.rs | 18 +++ src/labels/ctrm.rs | 301 ++++++++++++++++++++--------------- src/labels/dbp.rs | 14 ++ src/labels/fox.rs | 39 +++++ src/labels/jar.rs | 47 ++++++ src/labels/mpls_universal.rs | 118 +++++++++++++- 10 files changed, 701 insertions(+), 165 deletions(-) diff --git a/src/labels/bdj_feature.rs b/src/labels/bdj_feature.rs index 482cbd34..65bc16a5 100644 --- a/src/labels/bdj_feature.rs +++ b/src/labels/bdj_feature.rs @@ -551,6 +551,11 @@ mod tests { // A playlist: one play item of `secs` seconds with `n_audio` audio streams. fn build_mpls(secs: u32, n_audio: u8) -> Vec { + build_mpls_pg(secs, n_audio, 0) + } + + // As `build_mpls`, plus `n_pg` PG subtitle streams after the audio. + fn build_mpls_pg(secs: u32, n_audio: u8, n_pg: u8) -> Vec { let playlist_start: u32 = 40; let mut buf = Vec::new(); buf.extend_from_slice(b"MPLS0200"); @@ -582,11 +587,17 @@ mod tests { item.extend_from_slice(&[0u8; 2]); // reserved item.push(0); // n_video item.push(n_audio); // n_audio - item.extend_from_slice(&[0u8; 6]); // pg/ig/sec.../dv counts + item.push(n_pg); // n_pg + item.extend_from_slice(&[0u8; 5]); // ig/sec.../dv counts item.extend_from_slice(&[0u8; 4]); // reserved for i in 0..n_audio { item.extend_from_slice(&audio_stream_entry(0x1100 + i as u16)); } + for i in 0..n_pg { + item.extend_from_slice(&[3, 0x01]); + item.extend_from_slice(&(0x1200 + i as u16).to_be_bytes()); + item.extend_from_slice(&[4, 0x90, b'e', b'n', b'g']); + } let stn_len = (item.len() - stn_start - 2) as u16; item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes()); @@ -949,6 +960,83 @@ mod tests { assert_eq!(resolve(&mut disc, &udf, &[]), None); } + // Only AUTOSTART apps' jars are Tier-2 targets: a PRESENT-only decoy app's jar, + // naming a longer playlist, must not join the candidate set. + #[test] + fn tier2_ignores_non_autostart_apps() { + let auto_jar = build_jar(&[("com/studio/MainXlet.class", build_class(&["00800.mpls"]))]); + let decoy_jar = build_jar(&[("com/studio/Decoy.class", build_class(&["00801.mpls"]))]); + let (mut disc, udf) = build_disc( + vec![ + ("00800.mpls", build_mpls(7000, 6)), + ("00801.mpls", build_mpls(9000, 6)), + ], + vec![("00000.jar", auto_jar), ("00009.jar", decoy_jar)], + vec![("00000.bdjo", bdjo_with_apps(&[(1, "00000"), (2, "00009")]))], + ); + let hint = resolve(&mut disc, &udf, &[]).expect("Tier-2 hint"); + assert_eq!(hint.playlist_id, Some(800)); + } + + // A manifest over MAX_MANIFEST_BYTES is skipped, so Tier 2 decides. + #[test] + fn oversized_manifest_is_not_a_tier1_hint() { + let mut props = b"feature.playlist.id=00800\n".to_vec(); + props.resize(MAX_MANIFEST_BYTES + 16, b' '); + let jar = build_jar(&[ + ("app.properties", props), + ("com/studio/MainXlet.class", build_class(&["00801.mpls"])), + ]); + let (mut disc, udf) = build_disc( + vec![ + ("00800.mpls", build_mpls(7000, 6)), + ("00801.mpls", build_mpls(9000, 6)), + ], + vec![("00000.jar", jar)], + vec![], + ); + let hint = resolve(&mut disc, &udf, &[]).expect("Tier-2 hint"); + assert_eq!(hint.playlist_id, Some(801)); + } + + #[test] + fn props_hint_ignores_comment_lines() { + for text in [ + "# feature.playlist.id=00800\n", + "! feature.playlist.id=00800\n", + ] { + assert_eq!(props_hint(text, &|_| true), None, "{text}"); + } + } + + // Only primary audio counts toward the multi-audio gate, not PG subtitles. + #[test] + fn mpls_stats_counts_only_audio_streams() { + let (mut disc, udf) = build_disc( + vec![("00800.mpls", build_mpls_pg(7000, 1, 3))], + vec![], + vec![], + ); + assert_eq!(mpls_stats(&mut disc, &udf, 800), Some((7000, 1))); + } + + // Only `NNNNN.mpls` names (five digits) are playlists. + #[test] + fn real_playlist_ids_requires_five_digit_stems() { + let m = || build_mpls(60, 1); + let (_disc, udf) = build_disc( + vec![ + ("800.mpls", m()), + ("0800a.mpls", m()), + ("+0800.mpls", m()), + ("00801.mpls", m()), + ], + vec![], + vec![], + ); + assert_eq!(real_playlist_ids(&udf), HashSet::from([801])); + } + // A sector source that records every LBA it is asked for. struct Counting<'a>(&'a mut MemDisc, Vec); impl SectorSource for Counting<'_> { @@ -1095,6 +1183,11 @@ mod tests { // Build a minimal single-app BDJO whose AUTOSTART app has the given base_dir. fn bdjo_with_autostart(base_dir: &str) -> Vec { + bdjo_with_apps(&[(1, base_dir)]) + } + + // Build a BDJO with one app per `(control_code, base_dir)`; control code 1 is AUTOSTART. + fn bdjo_with_apps(apps: &[(u8, &str)]) -> Vec { fn app_string(buf: &mut Vec, s: &str) { buf.push(s.len() as u8); buf.extend_from_slice(s.as_bytes()); @@ -1115,26 +1208,28 @@ mod tests { b.extend_from_slice(&[0u8; 4]); // AccessiblePlaylists length b.extend_from_slice(&[0u8; 4]); // num_pl(11)+flags(2)+pad(19) = 0 b.extend_from_slice(&[0u8; 4]); // AMT length - b.push(1); // num_app + b.push(apps.len() as u8); // num_app b.push(0); // padding - // App record - b.push(1); // control_code = AUTOSTART - b.push(0); // type(4)+reserved(4) - b.extend_from_slice(&[0u8; 4]); // org_id - b.extend_from_slice(&[0u8; 2]); // app_id - b.extend_from_slice(&[0u8; 10]); // descriptor tag+length - b.push(0); // num_profile(4)+pad - b.push(0); - b.push(0); // priority - b.push(0); // binding/visibility/reserved - b.extend_from_slice(&[0u8; 2]); // app_name data_length = 0 - app_string(&mut b, ""); // icon_locator - b.extend_from_slice(&[0u8; 2]); // icon_flags - app_string(&mut b, base_dir); // base_dir - app_string(&mut b, ""); // classpath_extension - app_string(&mut b, "com.studio.MainXlet"); // initial_class - b.push(0); // params data_length = 0 - b.push(0); // word-align pad + for &(control_code, base_dir) in apps { + // App record + b.push(control_code); + b.push(0); // type(4)+reserved(4) + b.extend_from_slice(&[0u8; 4]); // org_id + b.extend_from_slice(&[0u8; 2]); // app_id + b.extend_from_slice(&[0u8; 10]); // descriptor tag+length + b.push(0); // num_profile(4)+pad + b.push(0); + b.push(0); // priority + b.push(0); // binding/visibility/reserved + b.extend_from_slice(&[0u8; 2]); // app_name data_length = 0 + app_string(&mut b, ""); // icon_locator + b.extend_from_slice(&[0u8; 2]); // icon_flags + app_string(&mut b, base_dir); // base_dir + app_string(&mut b, ""); // classpath_extension + app_string(&mut b, "com.studio.MainXlet"); // initial_class + b.push(0); // params data_length = 0 + b.push(0); // word-align pad + } b } } diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index 1e72b69d..5141edff 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -43,8 +43,8 @@ pub struct DiscMetadata { pub titles: BTreeMap, /// First-line / short description, per lang pub descriptions: BTreeMap, - /// Disc N of M, returned whenever both `` and - /// `` are present and sane (including `(1, 1)` for a + /// Disc N of M, returned whenever both `` (or the + /// `` fallback) and `` are present and sane (including `(1, 1)` for a /// single-disc release); `None` if either is missing or invalid. pub disc_number: Option<(u32, u32)>, } @@ -147,11 +147,8 @@ pub(crate) type BdmtFields = (String, Option, Option<(u32, u32)>); // could be located. pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option { let title = cap_text(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)) + .and_then(|s| field_text(&s)) .map(cap_text); let disc_set = extract_disc_set(xml_text); Some((title, description, disc_set)) @@ -174,6 +171,66 @@ fn looks_like_xml(s: &str) -> bool { }) } +/// Turn a raw element body into display text: a whole-value CDATA section is +/// unwrapped, otherwise the predefined and numeric character entities are decoded. +/// `None` for empty values and for values carrying element markup. +fn field_text(raw: &str) -> Option { + let raw = raw.trim(); + let out = match raw + .strip_prefix("")) + { + Some(inner) if !inner.contains("]]>") => inner.trim().to_string(), + Some(_) => return None, + None if looks_like_xml(raw) => return None, + None => decode_entities(raw), + }; + if out.is_empty() || looks_like_xml(&out) { + return None; + } + Some(out) +} + +// Decode `& < > " ' &#N; &#xH;`; anything else is kept verbatim. +fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + let decoded = rest.find(';').filter(|&e| e <= 10).and_then(|e| { + let c = match &rest[1..e] { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + n => { + let n = n.strip_prefix('#')?; + let cp = match n.strip_prefix(['x', 'X']) { + Some(h) => u32::from_str_radix(h, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(cp).filter(|c| !c.is_control())? + } + }; + Some((c, e + 1)) + }); + match decoded { + Some((c, len)) => { + out.push(c); + rest = &rest[len..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + /// 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). @@ -182,10 +239,7 @@ fn extract_title(xml_text: &str) -> Option { // tableOfContents/titleName. xml::text trims, so an empty string here // means a genuinely empty element. for tag in ["name", "title"] { - if let Some(s) = xml::text(xml_text, tag) - && !s.is_empty() - && !looks_like_xml(&s) - { + if let Some(s) = xml::text(xml_text, tag).and_then(|s| field_text(&s)) { return Some(s); } } @@ -193,9 +247,7 @@ fn extract_title(xml_text: &str) -> Option { // 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") - && !t.is_empty() - { + if let Some(t) = xml::text(block, "titleName").and_then(|s| field_text(&s)) { return Some(t); } } @@ -256,6 +308,35 @@ mod tests { assert!(d.expect("desc").len() <= MAX_BDMT_TEXT); } + #[test] + fn cap_text_cuts_on_a_char_boundary() { + let s = format!("aa{}", "\u{4e16}".repeat(400)); + let t = cap_text(s); + assert!(t.len() <= MAX_BDMT_TEXT && t.chars().all(|c| c == 'a' || c == '\u{4e16}')); + } + + #[test] + fn entities_and_cdata_are_decoded() { + let xml = "Tom & Jerry 's A\ + "; + let (t, d, _) = parse_bdmt_xml(xml).expect("parse"); + assert_eq!(t, "Tom & Jerry 's A"); + assert_eq!(d.as_deref(), Some("a < b & c")); + } + + #[test] + fn cdata_title_is_unwrapped() { + let xml = ""; + assert_eq!(parse_bdmt_xml(xml).expect("parse").0, "Real"); + } + + #[test] + fn toc_title_name_with_markup_is_rejected() { + let xml = "Real \ + "; + assert_eq!(parse_bdmt_xml(xml), None); + } + #[test] fn extract_simple_title() { // Minimal Paramount-style document: as the title diff --git a/src/labels/class_reader.rs b/src/labels/class_reader.rs index f7c8ae06..f358c810 100644 --- a/src/labels/class_reader.rs +++ b/src/labels/class_reader.rs @@ -1145,6 +1145,24 @@ mod tests { ); } + #[test] + fn modified_utf8_unpaired_surrogates_degrade_without_panicking() { + // high + high: each is lone. + assert_eq!( + decode_modified_utf8(&[0xED, 0xA0, 0xBD, 0xED, 0xA0, 0xBD]).unwrap(), + "\u{FFFD}\u{FFFD}" + ); + // high + ASCII, and a lone low surrogate. + assert_eq!( + decode_modified_utf8(&[0xED, 0xA0, 0xBD, b'a']).unwrap(), + "\u{FFFD}a" + ); + assert_eq!( + decode_modified_utf8(&[0xED, 0xB8, 0x80]).unwrap(), + "\u{FFFD}" + ); + } + #[test] fn modified_utf8_two_byte() { // U+00E9 'é' in the standard 2-byte modified-UTF-8 encoding diff --git a/src/labels/clpi_audit.rs b/src/labels/clpi_audit.rs index 016b1491..219fd206 100644 --- a/src/labels/clpi_audit.rs +++ b/src/labels/clpi_audit.rs @@ -660,4 +660,73 @@ mod tests { ); assert_eq!(a.class_counts(), (1, 0, 1, 0), "{:?}", a.rows); } + + // Audit one clip `00001` (CLPI `clpi`) against one playlist (`mpls`). + fn audit_one(clpi: Vec, mpls: Vec) -> ClpiVsMplsAudit { + use crate::udf::fixture::*; + let dir = |name: &str, icb: u32, files| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: icb + 1, + files, + subdirs: vec![], + }; + let clipinf = dir( + "CLIPINF", + 24, + vec![file_with("00001.clpi", 26, 8000, clpi, false)], + ); + let playlist = dir( + "PLAYLIST", + 30, + vec![file_with("00800.mpls", 32, 8200, mpls, false)], + ); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![clipinf, playlist], + }], + }; + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + audit(&mut disc, &udf) + } + + // A PID the playlist names but the CLPI lacks is a real MplsOnly row. + #[test] + fn playlist_pid_missing_from_clpi_is_mpls_only() { + use crate::consts::coding_type as c; + let build_clpi = super::super::clpi_orphan_tests::build_clpi; + let a = audit_one( + build_clpi(&[(0x1200, c::PG, "eng")]), + build_mpls(b"00001", 0x1100, c::TRUEHD, b"eng"), + ); + assert_eq!(a.class_counts(), (1, 1, 0, 0), "{:?}", a.rows); + } + + // PID 0 never makes a row, and a repeated PID in one clip keeps the first entry. + #[test] + fn pid_zero_is_skipped_and_first_duplicate_pid_wins() { + use crate::consts::coding_type as c; + let build_clpi = super::super::clpi_orphan_tests::build_clpi; + let a = audit_one( + build_clpi(&[ + (0, c::AC3, "eng"), + (0x1100, c::TRUEHD, "eng"), + (0x1100, c::AC3, "fra"), + ]), + build_mpls(b"00001", 0x1100, c::TRUEHD, b"eng"), + ); + assert_eq!(a.rows.len(), 1, "{:?}", a.rows); + assert_eq!(a.class_counts(), (0, 0, 1, 0), "{:?}", a.rows); + } } diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index a9e837ca..30163261 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -407,6 +407,24 @@ mod tests { assert_eq!(by_lang, [("eng", 2), ("fra", 1)]); } + // Two streams claiming the same number: the second is dropped, not emitted with the + // NO_STN_SLOT sentinel. + #[test] + fn parse_drops_a_stream_with_a_contradictory_number_claim() { + let sp = "a0ENG\ + a1FRA"; + let pc = "2a0\ + 2a1"; + let (mut disc, udf) = jar_disc(&[("streamproperties.xml", sp), ("playbackconfig.xml", pc)]); + let got = parse(&mut disc, &udf).expect("labels"); + let kept: Vec<(&str, u16)> = got + .labels + .iter() + .map(|l| (l.language.as_str(), l.stream_number)) + .collect(); + assert_eq!(kept, [("eng", 2)]); + } + // ── Additional hardening tests ───────────────────────────────────────── /// Spec: audio and subtitle counters are INDEPENDENT — audio fallback counter diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index 98124711..190758ec 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -226,10 +226,130 @@ fn parse_language_streams_text(text: &str) -> Vec { labels } -// NOTE: `parse_menu_base` / `parse_menu_base_text` structurally belong above this -// module but are left below (lint allowed) — a ~120-line move is safer as its -// own focused change. -#[allow(clippy::items_after_test_module)] +// ── menu_base.prop parser ────────────────────────────────────────────────── + +fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { + 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) +} + +// Parses menu_base.prop body into labels. Split from parse_menu_base +// (I/O + UTF-8 decode only) so tests exercise real parsing logic. +// Returns labels sorted by (type, number). +fn parse_menu_base_text(text: &str) -> Vec { + // Parse key=value, group by prefix + // BTreeMap: deterministic prefix order so equal (type, number) ties sort stably. + let mut entries: BTreeMap> = BTreeMap::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let eq_pos = match line.find('=') { + Some(p) => p, + None => continue, + }; + let full_key = line[..eq_pos].trim(); + let value = line[eq_pos + 1..].trim_start(); + + if let Some(dot_pos) = full_key.rfind('.') { + let prefix = full_key[..dot_pos].to_string(); + let key = full_key[dot_pos + 1..].to_string(); + if !entries.contains_key(&prefix) && entries.len() >= MAX_CTRM_LABELS { + continue; + } + let props = entries.entry(prefix).or_default(); + if props.len() < MAX_PROPS_PER_PREFIX || props.contains_key(&key) { + props.insert(key, value.to_string()); + } + } + } + + let mut labels = Vec::new(); + + for (prefix, props) in &entries { + // Audio: has "streamNumber" or "audioStream" and audio-related class + 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; + } + + // 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(); + + // Ask vocab first (word-boundary matched, avoiding the "Commenter" + // false positive of the old `name.contains("comment")`), then fall + // back to the structural prefix check (`audio_commentary.foo`-style). + let purpose = match vocab::purpose(&name) { + LabelPurpose::Normal if prefix_is_commentary(prefix) => LabelPurpose::Commentary, + p => p, + }; + + // 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 + }; + + // Try to extract language from audioLanguage/subtitleLanguage prop + let language = props + .get("audioLanguage") + .or_else(|| props.get("subtitleLanguage")) + .cloned() + .unwrap_or_default(); + + labels.push(StreamLabel { + stream_id: None, + 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 +} + #[cfg(test)] mod tests { use super::*; @@ -274,6 +394,55 @@ mod tests { assert_eq!(parse_language_streams_text(&ls).len(), MAX_CTRM_LABELS); } + #[test] + fn menu_base_sorts_by_type_then_number_across_prefixes() { + let labels = parse_props( + "a_sub.class=SubtitleButton\na_sub.streamNumber=1\n\ + b_aud.class=AudioButton\nb_aud.streamNumber=1\n\ + c_aud.class=AudioButton\nc_aud.streamNumber=2\n\ + d_aud.class=AudioButton\nd_aud.streamNumber=1\nd_aud.name=Low\n", + ); + let key: Vec<(StreamLabelType, u16)> = labels + .iter() + .map(|l| (l.stream_type, l.stream_number)) + .collect(); + assert_eq!( + key, + vec![ + (StreamLabelType::Audio, 1), + (StreamLabelType::Audio, 1), + (StreamLabelType::Audio, 2), + (StreamLabelType::Subtitle, 1), + ] + ); + } + + #[test] + fn menu_base_caps_properties_per_prefix() { + let mut text = String::from("audio_1.streamNumber=1\n"); + for i in 0..(MAX_PROPS_PER_PREFIX + 50) { + text.push_str(&format!("audio_1.junk{i}=x\n")); + } + // Past the cap: a new key is dropped, an already-retained key is still updated. + text.push_str("audio_1.late=x\naudio_1.name=Late\naudio_1.streamNumber=2\n"); + let labels = parse_props(&text); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].stream_number, 2); + assert_eq!(labels[0].name, ""); + } + + #[test] + fn menu_base_accepts_alternate_stream_and_language_keys() { + let labels = parse_props( + "audio_1.audioStream=4\n\ + subtitle_1.subtitleStream=5\nsubtitle_1.subtitleLanguage=fra\n", + ); + assert_eq!(labels.len(), 2); + assert_eq!(labels[0].stream_number, 4); + assert_eq!(labels[1].stream_number, 5); + assert_eq!(labels[1].language, "fra"); + } + #[test] fn merge_fills_names_and_appends_only_missing() { let mk = |n: u16, name: &str| StreamLabel { @@ -810,127 +979,3 @@ mod tests { assert!(prefix_is_commentary("comm_track_1")); } } - -// ── menu_base.prop parser ────────────────────────────────────────────────── - -fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { - 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) -} - -// Parses menu_base.prop body into labels. Split from parse_menu_base -// (I/O + UTF-8 decode only) so tests exercise real parsing logic. -// Returns labels sorted by (type, number). -fn parse_menu_base_text(text: &str) -> Vec { - // Parse key=value, group by prefix - // BTreeMap: deterministic prefix order so equal (type, number) ties sort stably. - let mut entries: BTreeMap> = BTreeMap::new(); - - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let eq_pos = match line.find('=') { - Some(p) => p, - None => continue, - }; - let full_key = line[..eq_pos].trim(); - let value = line[eq_pos + 1..].trim_start(); - - if let Some(dot_pos) = full_key.rfind('.') { - let prefix = full_key[..dot_pos].to_string(); - let key = full_key[dot_pos + 1..].to_string(); - if !entries.contains_key(&prefix) && entries.len() >= MAX_CTRM_LABELS { - continue; - } - let props = entries.entry(prefix).or_default(); - if props.len() < MAX_PROPS_PER_PREFIX || props.contains_key(&key) { - props.insert(key, value.to_string()); - } - } - } - - let mut labels = Vec::new(); - - for (prefix, props) in &entries { - // Audio: has "streamNumber" or "audioStream" and audio-related class - 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; - } - - // 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(); - - // Ask vocab first (word-boundary matched, avoiding the "Commenter" - // false positive of the old `name.contains("comment")`), then fall - // back to the structural prefix check (`audio_commentary.foo`-style). - let purpose = match vocab::purpose(&name) { - LabelPurpose::Normal if prefix_is_commentary(prefix) => LabelPurpose::Commentary, - p => p, - }; - - // 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 - }; - - // Try to extract language from audioLanguage/subtitleLanguage prop - let language = props - .get("audioLanguage") - .or_else(|| props.get("subtitleLanguage")) - .cloned() - .unwrap_or_default(); - - labels.push(StreamLabel { - stream_id: None, - 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 -} diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index b41bcd1c..38bf7db0 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -454,6 +454,20 @@ mod tests { assert_eq!(subs[&2], "Spanish"); } + #[test] + fn collect_skips_entries_with_an_empty_or_missing_label() { + let mut audios = BTreeMap::new(); + let mut subs = BTreeMap::new(); + for s in [ + "XTextField,Audio1,,rest", + "XTextField,Audio2", + "XTextField,Subtitle1, ,rest", + ] { + collect_textfield(s, &mut audios, &mut subs); + } + assert!(audios.is_empty() && subs.is_empty()); + } + #[test] fn collect_ignores_non_textfield_strings() { let mut audios = BTreeMap::new(); diff --git a/src/labels/fox.rs b/src/labels/fox.rs index c597504a..e463adb8 100644 --- a/src/labels/fox.rs +++ b/src/labels/fox.rs @@ -528,6 +528,45 @@ mod tests { assert_eq!(feature_hint(doc).and_then(|h| h.playlist_id), Some(900)); } + /// A full tie (same stream count, same `durs`) keeps the first feature. + #[test] + fn full_tie_keeps_the_first_feature() { + let doc = r#" + + + "#; + assert_eq!(feature_hint(doc).and_then(|h| h.playlist_id), Some(800)); + } + + /// Stream count outranks duration: a shorter but richer feature wins. + #[test] + fn stream_count_outranks_duration() { + let doc = r#" + + + + "#; + assert_eq!(feature_hint(doc).and_then(|h| h.playlist_id), Some(900)); + } + + /// Attribute values from the untrusted manifest are matched case-insensitively. + #[test] + fn manifest_attribute_values_are_case_insensitive() { + let doc = r#" + + + "#; + let labels = labels_from_dcx(doc); + assert_eq!(labels.len(), 3); + assert_eq!(labels[0].purpose, LabelPurpose::Descriptive); + assert_eq!(labels[1].qualifier, LabelQualifier::Sdh); + assert_eq!(labels[2].qualifier, LabelQualifier::Forced); + } + /// A stated sub-minute `name="feature"` is a decoy and is skipped in favour /// of the real (longer) feature. #[test] diff --git a/src/labels/jar.rs b/src/labels/jar.rs index b5eef34c..c0382526 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -710,6 +710,53 @@ mod tests { ); } + #[test] + fn is_jar_matches_files_with_a_case_insensitive_extension() { + let entry = |name: &str, is_dir| crate::udf::DirEntry { + name: name.to_string(), + is_dir, + meta_lba: 0, + size: 0, + entries: Vec::new(), + }; + assert!(is_jar(&entry("00000.jar", false))); + assert!(is_jar(&entry("00000.JAR", false))); + assert!(!is_jar(&entry("00000.jar", true))); + assert!(!is_jar(&entry("00000.xml", false))); + } + + // Directory entries and upper-case `.CLASS` bytecode are never offered as resources. + #[test] + fn try_each_resource_skips_directory_entries_and_upper_case_class() { + use std::io::{Cursor, Write as _}; + let mut buf = Vec::new(); + { + let mut w = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + w.add_directory("assets/", opts).unwrap(); + w.start_file("com/x/Menu.CLASS", opts).unwrap(); + w.write_all(MINIMAL_CLASS).unwrap(); + w.start_file("assets/a.xml", opts).unwrap(); + w.write_all(b"").unwrap(); + w.finish().unwrap(); + } + let mut jar = open(buf); + let mut seen: Vec = Vec::new(); + let mut budget = u64::MAX; + let _: Option<()> = try_each_resource( + &mut jar, + |_| true, + 1024, + &mut budget, + |name, _| { + seen.push(name.to_string()); + None + }, + ); + assert_eq!(seen, ["assets/a.xml"]); + } + // An entry the name filter rejects is never inflated: it costs no budget. #[test] fn unwanted_resources_are_not_inflated() { diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index 0bb3b5f5..6857f622 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -15,6 +15,10 @@ use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::HashSet; +// Retained-label cap: a crafted image can carry tens of thousands of playlists, each +// naming distinct clips with a full STN table. +const MAX_MPLS_LABELS: usize = 4096; + /// True iff `/BDMV/PLAYLIST/` exists and contains at least one /// `.mpls` file. Cheap directory walk only — no sector reads. pub fn detect(_reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { @@ -54,6 +58,9 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option let mut labels: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); for name in &mpls_names { + if labels.len() >= MAX_MPLS_LABELS { + break; + } let path = format!("/BDMV/PLAYLIST/{}", name); let Ok(data) = udf.read_file(reader, &path) else { continue; @@ -123,6 +130,9 @@ fn add_playlist_labels( clip_id: clip_id.clone(), pid: entry.pid, }; + if labels.len() >= MAX_MPLS_LABELS { + return; + } if !seen.insert(stream_id.clone()) { continue; } @@ -156,16 +166,18 @@ fn label_type_for(entry: &crate::mpls::StreamEntry) -> Option { match entry.stream_type { 2 | 5 if entry.coding_type == c::PG => Some(StreamLabelType::Subtitle), 2 | 5 => Some(StreamLabelType::Audio), + // PiP PG is the secondary-video overlay, not a stream of the title. + 3 if entry.secondary => None, 3 => Some(StreamLabelType::Subtitle), _ => None, } } fn has_mpls_extension(name: &str) -> bool { - // Case-insensitive ".mpls" suffix (discs use both cases). UDF names are - // decoded via from_utf8_lossy, so a multi-byte replacement char can straddle - // byte index n-5; `ends_with` on a lowercased copy avoids that boundary panic. - name.len() >= 5 && name.to_ascii_lowercase().ends_with(".mpls") + // Case-insensitive ".mpls" suffix (discs use both cases). Compared on a lowercased + // copy, never by slicing: UDF names are lossily decoded, so byte n-5 may fall + // inside a multi-byte replacement char. + name.to_ascii_lowercase().ends_with(".mpls") } // Lowercase + trim the raw 3-char ISO 639-2 code; if vocab::lang maps @@ -365,6 +377,104 @@ mod tests { build_labels(playlists) } + // Minimal MPLS: one play item on `clip` whose STN lists one primary audio stream `pid`. + fn mpls_bytes(clip: &[u8; 5], pid: u16) -> Vec { + let mut item = Vec::new(); + item.extend_from_slice(clip); + item.extend_from_slice(b"M2TS"); + item.extend_from_slice(&[0u8; 3]); + item.extend_from_slice(&0u32.to_be_bytes()); + item.extend_from_slice(&(7000u32 * 45000).to_be_bytes()); + item.extend_from_slice(&[0u8; 12]); // UO mask, misc, still + item.extend_from_slice(&[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + item.extend_from_slice(&[3, 0x01]); + item.extend_from_slice(&pid.to_be_bytes()); + item.extend_from_slice(&[5, 0x81, 0x61]); + item.extend_from_slice(b"eng"); + let mut pl = vec![0u8; 6]; + pl.extend_from_slice(&1u16.to_be_bytes()); + pl.extend_from_slice(&[0u8; 2]); + pl.extend_from_slice(&(item.len() as u16).to_be_bytes()); + pl.extend_from_slice(&item); + let pl_len = (pl.len() - 4) as u32; + pl[0..4].copy_from_slice(&pl_len.to_be_bytes()); + let mut buf = b"MPLS0200".to_vec(); + buf.extend_from_slice(&40u32.to_be_bytes()); + buf.extend_from_slice(&[0u8; 28]); + buf.extend_from_slice(&pl); + buf + } + + // The same stream named by two playlists yields one label; an unparseable playlist + // between them is skipped, not fatal. + #[test] + fn parse_dedups_across_playlists_and_skips_a_bad_one() { + use crate::udf::fixture::*; + let files = vec![ + file_with("00800.mpls", 32, 8200, mpls_bytes(b"00001", 0x1100), false), + file_with("00801.mpls", 33, 8300, b"not an mpls".to_vec(), false), + file_with("00802.mpls", 34, 8400, mpls_bytes(b"00001", 0x1100), false), + ]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "PLAYLIST".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files, + subdirs: vec![], + }], + }], + }; + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + let got = parse(&mut disc, &udf).expect("labels"); + assert_eq!(got.labels.len(), 1); + } + + #[test] + fn playlist_without_play_items_yields_no_labels() { + let mut pl = playlist_with(vec![audio_entry(0x1100, 0x81, 6, 1, "eng")]); + pl.play_items.clear(); + assert!(labels_from_playlists(&[pl]).is_empty()); + } + + #[test] + fn pip_pg_streams_are_not_labelled_and_take_no_slot() { + let mut pip = pg_entry(0x1B00, "eng"); + pip.secondary = true; + let pl = playlist_with(vec![pg_entry(0x1200, "eng"), pip, pg_entry(0x1201, "fra")]); + let labels = labels_from_playlists(&[pl]); + let got: Vec<(u16, u16)> = labels + .iter() + .map(|l| (l.stream_id.as_ref().unwrap().pid, l.stream_number)) + .collect(); + assert_eq!(got, [(0x1200, 1), (0x1201, 2)]); + } + + #[test] + fn retained_labels_are_capped() { + let playlists: Vec = (0..MAX_MPLS_LABELS + 50) + .map(|i| { + playlist_on( + &format!("{i:05}"), + vec![audio_entry(0x1100, 0x81, 6, 1, "eng")], + ) + }) + .collect(); + assert_eq!(labels_from_playlists(&playlists).len(), MAX_MPLS_LABELS); + } + #[test] fn mpls_audio_streams_become_labels() { // Two audio streams: English TrueHD combo 48k, French AC-3 5.1 48k. From 19066aa7715270a48c99eb98290e434ddf3d86b9 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:02:36 -0700 Subject: [PATCH 2/4] labels: log caught parser panics, pin label tables and selection rules with tests --- src/labels/mod.rs | 394 +++++++++++++++++++++++++++++++++++++--- src/labels/paramount.rs | 54 ++++++ src/labels/pixelogic.rs | 49 +++++ src/labels/vocab.rs | 66 +++++++ 4 files changed, 542 insertions(+), 21 deletions(-) diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 767051ca..850eee8b 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -302,8 +302,16 @@ pub fn apply( titles: &mut [DiscTitle], ) -> Option { let (labels, winner_hint) = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) - .unwrap_or_default(); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) { + Ok(r) => r, + Err(payload) => { + tracing::warn!( + panic = %panic_message(payload.as_ref()), + "label extraction panicked; streams left unlabelled" + ); + Default::default() + } + }; if !labels.is_empty() { apply_labels(&labels, titles); } @@ -313,11 +321,26 @@ pub fn apply( let hint = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { resolve_feature_hint(reader, udf, winner_hint, titles) })) - .ok() - .flatten(); + .unwrap_or_else(|payload| { + tracing::warn!( + panic = %panic_message(payload.as_ref()), + "feature-hint resolution panicked; no hint" + ); + None + }); hint.filter(|h| !h.is_empty()) } +// A caught panic's message, control characters escaped (it can carry disc-derived text). +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + let msg = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload"); + msg.escape_debug().to_string() +} + // The disc's feature-playlist hint: the winning parser's manifest hint when it // carries one, else the generic BD-J menu-walk (`bdj_feature::resolve`). Kept // separate from extraction so a hint survives an empty/absent label set. @@ -1172,7 +1195,8 @@ pub struct LabelAnalysis { /// MPLS itself was the chosen parser. Diagnostic for the labels-analyze /// tool. pub gap_fill_added: usize, - /// Per-playlist chapter summary: `(playlist_filename, chapter_count, duration_secs)`. + /// Per-playlist chapter summary rows ([`ChapterSummary`]): playlist filename, + /// chapter count, duration in seconds. /// Sourced from MPLS PlaylistMark entries with `mark_type == 1` /// (chapter entries). Ordered by playlist filename. Empty if no /// MPLS files have parseable marks, or the disc isn't Blu-ray. @@ -1465,22 +1489,6 @@ mod registry_tests { 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). - #[test] - fn parsers_registry_all_entries_populated() { - for (name, detect, parse) in PARSERS { - // Function pointers can't be null in safe Rust, so this just fails to - // compile if the tuple shape changes (e.g. a 4th field is added) - // without updating callers; it also marks "these parsers exist." - let _ = (name, detect, parse); - } - // Non-empty invariant is covered separately by - // `parsers_registry_order_locked`, whose assert_eq! on the expected - // order fails if PARSERS is empty. - } } // ── MPLS floor merge tests ───────────────────────────────────────────────── @@ -3409,3 +3417,347 @@ mod feature_hint_pass_tests { ); } } + +// ── Label text tables, ordering, and extraction ─────────────────────────── + +#[cfg(test)] +mod label_table_tests { + use super::*; + use crate::disc::{AudioChannels, Codec, HdrFormat, Stream, SubtitleStream}; + use crate::udf::fixture::*; + + fn vendor(t: StreamLabelType, n: u16) -> StreamLabel { + StreamLabel { + stream_id: None, + stream_number: n, + stream_type: t, + language: "eng".into(), + name: String::new(), + purpose: LabelPurpose::Normal, + qualifier: LabelQualifier::None, + codec_hint: String::new(), + variant: String::new(), + } + } + + fn derived(clip: &str, pid: u16, n: u16) -> StreamLabel { + StreamLabel { + stream_id: Some(StreamId { + clip_id: clip.into(), + pid, + }), + ..vendor(StreamLabelType::Audio, n) + } + } + + #[test] + fn panic_message_escapes_control_characters() { + let p = std::panic::catch_unwind(|| panic!("bad \x1b[31m disc")).unwrap_err(); + assert_eq!(panic_message(p.as_ref()), "bad \\u{1b}[31m disc"); + } + + #[test] + fn iso639_b_codes_map_to_their_t_codes() { + for (b, t) in [ + ("alb", "sqi"), + ("arm", "hye"), + ("baq", "eus"), + ("bur", "mya"), + ("chi", "zho"), + ("cze", "ces"), + ("dut", "nld"), + ("fre", "fra"), + ("geo", "kat"), + ("ger", "deu"), + ("gre", "ell"), + ("ice", "isl"), + ("mac", "mkd"), + ("mao", "mri"), + ("may", "msa"), + ("per", "fas"), + ("rum", "ron"), + ("slo", "slk"), + ("tib", "bod"), + ("wel", "cym"), + ] { + assert_eq!(to_iso639_t(b), t, "{b}"); + assert_eq!(to_iso639_t(&b.to_ascii_uppercase()), t, "{b}"); + assert!(same_language(b, t), "{b}"); + } + assert_eq!(to_iso639_t("eng"), "eng"); + } + + #[test] + fn audio_channel_layouts_have_their_own_text() { + for (ch, want) in [ + (AudioChannels::Mono, "1.0"), + (AudioChannels::Stereo, "2.0"), + (AudioChannels::Stereo21, "2.1"), + (AudioChannels::Surround30, "3.0"), + (AudioChannels::Surround31, "3.1"), + (AudioChannels::Quad, "4.0"), + (AudioChannels::Surround41, "4.1"), + (AudioChannels::Surround50, "5.0"), + (AudioChannels::Surround51, "5.1"), + (AudioChannels::Surround60, "6.0"), + (AudioChannels::Surround61, "6.1"), + (AudioChannels::Surround70, "7.0"), + (AudioChannels::Surround71, "7.1"), + ] { + assert_eq!( + generate_audio_label(&Codec::Ac3, &ch, false), + format!("Dolby Digital {want}") + ); + } + } + + #[test] + fn video_resolution_token_boundaries() { + let tok = |w, h, i| generate_video_label(&Codec::Hevc, (w, h), i, &HdrFormat::Sdr, false); + let name = Codec::Hevc.name(); + for (w, h, interlaced, want) in [ + (7680, 4320, false, "8K"), + (7679, 4320, false, "4K"), + (3840, 2160, false, "4K"), + (1920, 1080, false, "1080p"), + (1920, 1080, true, "1080i"), + (1280, 720, false, "720p"), + (720, 576, false, "576p"), + (720, 576, true, "576i"), + (720, 480, false, "480p"), + (720, 480, true, "480i"), + (640, 479, false, ""), + ] { + let want = if want.is_empty() { + name.to_string() + } else { + format!("{name} {want}") + }; + assert_eq!(tok(w, h, interlaced), want, "{w}x{h} i={interlaced}"); + } + } + + #[test] + fn atmos_marker_only_rides_a_dolby_carrier() { + let l = |c| generate_audio_label_atmos(&c, &AudioChannels::Surround71, false); + assert_eq!(l(Codec::DtsHdMa), "DTS-HD Master Audio 7.1"); + assert_eq!(l(Codec::Dts), "DTS 7.1"); + assert_eq!(l(Codec::Ac3), "Dolby Digital 7.1"); + } + + #[test] + fn atmos_hint_is_consistent_only_with_a_dolby_carrier() { + assert!(codec_hint_consistent("Atmos", &Codec::TrueHd)); + assert!(codec_hint_consistent("Atmos", &Codec::Ac3Plus)); + assert!(!codec_hint_consistent("Atmos", &Codec::DtsHdMa)); + assert!(!codec_hint_consistent("Atmos", &Codec::Dts)); + assert!(codec_hint_consistent("High Resolution", &Codec::DtsHdHr)); + assert!(codec_hint_consistent("DTS-HD HR", &Codec::DtsHdHr)); + assert!(!codec_hint_consistent("Master Audio", &Codec::DtsHdHr)); + assert!(!codec_hint_consistent("High Resolution", &Codec::DtsHdMa)); + } + + #[test] + fn merged_labels_order_by_slot_then_stream() { + let mut labels = vec![ + vendor(StreamLabelType::Audio, 3), + vendor(StreamLabelType::Audio, 1), + derived("00002", 0x1100, 1), + derived("00001", 0x1101, 1), + derived("00001", 0x1100, 1), + derived("00001", 0x1102, 0), + ]; + sort_labels(&mut labels); + let order: Vec<(Option<(&str, u16)>, u16)> = labels + .iter() + .map(|l| { + ( + l.stream_id.as_ref().map(|i| (i.clip_id.as_str(), i.pid)), + l.stream_number, + ) + }) + .collect(); + assert_eq!( + order, + [ + (None, 1), + (None, 3), + (Some(("00001", 0x1102)), 0), + (Some(("00001", 0x1100)), 1), + (Some(("00001", 0x1101)), 1), + (Some(("00002", 0x1100)), 1), + ] + ); + } + + // Off the authoritative path, a label binds by bare ordinal only when the stream + // STATES the same language; an unknown stream language is not agreement. + #[test] + fn ordinal_binding_needs_a_stated_subtitle_language() { + let mut title = DiscTitle { + playlist: "00800.mpls".into(), + playlist_id: 800, + duration_secs: 7200.0, + size_bytes: 0, + clips: Vec::new(), + streams: vec![Stream::Subtitle(SubtitleStream { + pid: 0x12A0, + codec: Codec::Pgs, + language: String::new(), + forced: false, + qualifier: LabelQualifier::None, + codec_data: None, + })], + chapters: Vec::new(), + extents: Vec::new(), + content_format: crate::disc::ContentFormat::BdTs, + codec_privates: Vec::new(), + }; + let labels = vec![StreamLabel { + qualifier: LabelQualifier::Forced, + ..vendor(StreamLabelType::Subtitle, 1) + }]; + apply_labels(&labels, std::slice::from_mut(&mut title)); + let Stream::Subtitle(s) = &title.streams[0] else { + unreachable!() + }; + assert!(!s.forced); + assert_eq!(s.qualifier, LabelQualifier::None); + } + + // Minimal MPLS: one play item on `clip` whose STN lists one primary audio stream `pid`. + fn mpls_bytes(clip: &[u8; 5], pid: u16) -> Vec { + let mut item = Vec::new(); + item.extend_from_slice(clip); + item.extend_from_slice(b"M2TS"); + item.extend_from_slice(&[0u8; 3]); + item.extend_from_slice(&0u32.to_be_bytes()); + item.extend_from_slice(&(7000u32 * 45000).to_be_bytes()); + item.extend_from_slice(&[0u8; 12]); // UO mask, misc, still + item.extend_from_slice(&[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + item.extend_from_slice(&[3, 0x01]); + item.extend_from_slice(&pid.to_be_bytes()); + item.extend_from_slice(&[5, 0x81, 0x61]); + item.extend_from_slice(b"eng"); + let mut pl = vec![0u8; 6]; + pl.extend_from_slice(&1u16.to_be_bytes()); + pl.extend_from_slice(&[0u8; 2]); + pl.extend_from_slice(&(item.len() as u16).to_be_bytes()); + pl.extend_from_slice(&item); + let pl_len = (pl.len() - 4) as u32; + pl[0..4].copy_from_slice(&pl_len.to_be_bytes()); + let mut buf = b"MPLS0200".to_vec(); + buf.extend_from_slice(&40u32.to_be_bytes()); + buf.extend_from_slice(&[0u8; 28]); + buf.extend_from_slice(&pl); + buf + } + + fn dir(name: &str, icb: u32, files: Vec, subdirs: Vec) -> DirSpec { + DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: icb + 1, + files, + subdirs, + } + } + + fn open(root: DirSpec) -> (MemDisc, UdfFs) { + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + (disc, udf) + } + + // A framework winner keeps its own labels and hint, and the MPLS floor adds the + // streams it named no label for. + #[test] + fn extract_merges_the_mpls_floor_into_a_framework_winner() { + let dcx = br#" + + "#; + let (mut disc, udf) = open(dir( + "", + 10, + vec![], + vec![dir( + "BDMV", + 20, + vec![], + vec![ + dir( + "JAR", + 30, + vec![], + vec![dir( + "00000", + 40, + vec![file_with("dcx.xml", 100, 2000, dcx.to_vec(), true)], + vec![], + )], + ), + dir( + "PLAYLIST", + 50, + vec![file_with( + "00800.mpls", + 101, + 3000, + mpls_bytes(b"00001", 0x1100), + true, + )], + vec![], + ), + ], + )], + )); + let (labels, hint) = extract(&mut disc, &udf); + assert_eq!(labels.iter().filter(|l| l.stream_id.is_none()).count(), 1); + assert_eq!(labels.iter().filter(|l| l.stream_id.is_some()).count(), 1); + assert_eq!(hint.and_then(|h| h.playlist_id), Some(800)); + } + + // The first copy of a manifest that reads non-empty wins; names match without case. + #[test] + fn read_jar_file_skips_empty_copies_and_matches_names_without_case() { + let (mut disc, udf) = open(dir( + "", + 10, + vec![], + vec![dir( + "BDMV", + 20, + vec![], + vec![dir( + "JAR", + 30, + vec![], + vec![ + dir( + "00000", + 40, + vec![file_with("playlists.xml", 100, 2000, Vec::new(), true)], + vec![], + ), + dir( + "00001", + 50, + vec![file_with( + "PLAYLISTS.XML", + 101, + 3000, + b"

".to_vec(), + true, + )], + vec![], + ), + ], + )], + )], + )); + let got = read_jar_file(&mut disc, &udf, "playlists.xml"); + assert_eq!(got.as_deref(), Some(&b"

"[..])); + } +} diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index 307f49cd..028bfa69 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -342,6 +342,60 @@ fn pick<'a>(cands: &[Candidate<'a>], key: Key, require_audio: bool) -> Option<&' mod tests { use super::*; + fn hint_id(doc: &str) -> Option { + feature_hint(doc).and_then(|h| h.playlist_id) + } + + // Tier 1 ranks exact `Feature` names by duration, not document order. + #[test] + fn exact_feature_tier_prefers_the_longest_duration() { + let doc = r#" + + + "#; + assert_eq!(hint_id(doc), Some(2)); + } + + // A zero-audio exact `Feature` still wins over an audio-bearing extra: its id feeds the hint. + #[test] + fn zero_audio_exact_feature_is_still_selected() { + let doc = r#" + + + "#; + assert_eq!(hint_id(doc), Some(800)); + } + + // With equal audio counts, tier 3 breaks the tie by the longer duration. + #[test] + fn most_audio_tier_breaks_a_tie_by_longer_duration() { + let doc = r#" + + + "#; + assert_eq!(hint_id(doc), Some(2)); + } + + // Exactly the minimum feature length is a feature; one second less is a decoy. + #[test] + fn minimum_feature_length_is_inclusive() { + let doc = |secs: u64| format!(r#""#); + assert_eq!(hint_id(&doc(60)), Some(800)); + assert_eq!(hint_id(&doc(59)), None); + } + + // Every duration attribute spelling arms the sub-minute decoy guard. + #[test] + fn every_duration_attribute_spelling_is_honoured() { + for key in ["duration", "durs", "dur", "runtime", "length", "len"] { + let doc = format!( + r#" + "# + ); + assert_eq!(hint_id(&doc), Some(800), "{key}"); + } + } + // Immunity pin, section-boundary half. #[test] fn a_playlists_stream_list_cannot_run_into_the_next_playlist() { diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 57063b1e..8e4c07b3 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -1186,4 +1186,53 @@ mod tests { assert!(!is_stream_token("Video Stream 1")); assert!(!is_stream_token("entry-markEa9")); } + + // A notice-clip name (`deu_Warning`) has the token shape: it is recorded as an + // uncatalogued component, yields no label, and still occupies its stream slot. + #[test] + fn notice_clip_token_is_recorded_unlabelled_and_takes_a_slot() { + let mut acc = UnknownParts::default(); + assert!(parse_token_inner("deu_Warning", Some(&mut acc)).is_none()); + assert_eq!(acc.seen.iter().collect::>(), ["WARNING"]); + + let mut acc = UnknownParts::default(); + let labels = assign_labels( + &strs(&["FPL_Main", "Audio Stream 1", "deu_Warning", "eng_MLP_"]), + &mut acc, + ); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0].stream_number, 3); + assert_eq!(acc.total, 1); + } + + // Lay `bluray_project.bin` into /BDMV/JAR/00000 and run the real `parse`. + fn parse_blob(blob: &[u8]) -> Option { + use crate::udf::fixture::*; + let dir = |name: &str, icb, data, files, subdirs| DirSpec { + name: name.to_string(), + icb_lba: icb, + dir_data_lba: data, + files, + subdirs, + }; + let file = file_with("bluray_project.bin", 100, 2000, blob.to_vec(), true); + let sub = dir("00000", 54, 55, vec![file], vec![]); + let jar = dir("JAR", 52, 53, vec![], vec![sub]); + let bdmv = dir("BDMV", 12, 13, vec![], vec![jar]); + let root = dir("", 10, 11, vec![], vec![bdmv]); + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + parse(&mut disc, &udf) + } + + // Confidence is High only when every token component was catalogued. + #[test] + fn parse_confidence_reflects_uncatalogued_components() { + let known = parse_blob(b"FPL_Main\0Audio Stream 1\0eng_MLP_\0").expect("labels"); + assert_eq!(known.confidence, Confidence::High); + let unknown = parse_blob(b"FPL_Main\0Audio Stream 1\0eng_MLP_NEWFLAG_\0").expect("labels"); + assert_eq!(unknown.confidence, Confidence::Medium); + } } diff --git a/src/labels/vocab.rs b/src/labels/vocab.rs index b6c88368..252c38fb 100644 --- a/src/labels/vocab.rs +++ b/src/labels/vocab.rs @@ -495,6 +495,72 @@ fn has_word(haystack: &str, needle: &str) -> bool { mod tests { use super::*; + // ISO 639-1 -> 639-2/T, checked against an independently written list so a mistyped or + // swapped row cannot pass on the table's own shape. + #[test] + fn iso639_1_values_match_the_standard() { + let want = [ + "aa:aar", "ab:abk", "ae:ave", "af:afr", "ak:aka", "am:amh", "an:arg", "ar:ara", + "as:asm", "av:ava", "ay:aym", "az:aze", "ba:bak", "be:bel", "bg:bul", "bh:bih", + "bi:bis", "bm:bam", "bn:ben", "bo:bod", "br:bre", "bs:bos", "ca:cat", "ce:che", + "ch:cha", "co:cos", "cr:cre", "cs:ces", "cu:chu", "cv:chv", "cy:cym", "da:dan", + "de:deu", "dv:div", "dz:dzo", "ee:ewe", "el:ell", "en:eng", "eo:epo", "es:spa", + "et:est", "eu:eus", "fa:fas", "ff:ful", "fi:fin", "fj:fij", "fo:fao", "fr:fra", + "fy:fry", "ga:gle", "gd:gla", "gl:glg", "gn:grn", "gu:guj", "gv:glv", "ha:hau", + "he:heb", "hi:hin", "ho:hmo", "hr:hrv", "ht:hat", "hu:hun", "hy:hye", "hz:her", + "ia:ina", "id:ind", "ie:ile", "ig:ibo", "ii:iii", "ik:ipk", "io:ido", "is:isl", + "it:ita", "iu:iku", "ja:jpn", "jv:jav", "ka:kat", "kg:kon", "ki:kik", "kj:kua", + "kk:kaz", "kl:kal", "km:khm", "kn:kan", "ko:kor", "kr:kau", "ks:kas", "ku:kur", + "kv:kom", "kw:cor", "ky:kir", "la:lat", "lb:ltz", "lg:lug", "li:lim", "ln:lin", + "lo:lao", "lt:lit", "lu:lub", "lv:lav", "mg:mlg", "mh:mah", "mi:mri", "mk:mkd", + "ml:mal", "mn:mon", "mr:mar", "ms:msa", "mt:mlt", "my:mya", "na:nau", "nb:nob", + "nd:nde", "ne:nep", "ng:ndo", "nl:nld", "nn:nno", "no:nor", "nr:nbl", "nv:nav", + "ny:nya", "oc:oci", "oj:oji", "om:orm", "or:ori", "os:oss", "pa:pan", "pi:pli", + "pl:pol", "ps:pus", "pt:por", "qu:que", "rm:roh", "rn:run", "ro:ron", "ru:rus", + "rw:kin", "sa:san", "sc:srd", "sd:snd", "se:sme", "sg:sag", "si:sin", "sk:slk", + "sl:slv", "sm:smo", "sn:sna", "so:som", "sq:sqi", "sr:srp", "ss:ssw", "st:sot", + "su:sun", "sv:swe", "sw:swa", "ta:tam", "te:tel", "tg:tgk", "th:tha", "ti:tir", + "tk:tuk", "tl:tgl", "tn:tsn", "to:ton", "tr:tur", "ts:tso", "tt:tat", "tw:twi", + "ty:tah", "ug:uig", "uk:ukr", "ur:urd", "uz:uzb", "ve:ven", "vi:vie", "vo:vol", + "wa:wln", "wo:wol", "xh:xho", "yi:yid", "yo:yor", "za:zha", "zh:zho", "zu:zul", + ]; + assert_eq!(want.len(), 184); + for pair in want { + let (two, three) = pair.split_once(':').unwrap(); + assert_eq!(iso639_1_to_iso639_2(two), Some(three), "{two}"); + } + } + + #[test] + fn iso639_1_withdrawn_dvd_codes_resolve_to_their_replacements() { + for (old, three) in [ + ("iw", "heb"), + ("in", "ind"), + ("ji", "yid"), + ("jw", "jav"), + ("mo", "ron"), + ] { + assert_eq!(iso639_1_to_iso639_2(old), Some(three), "{old}"); + } + } + + #[test] + fn mandarin_chinese_compound_keeps_its_variant() { + assert_eq!( + lang("Mandarin Chinese"), + Some(LangInfo { + code: "zho", + variant: "Mandarin" + }) + ); + } + + #[test] + fn description_word_marks_descriptive_purpose() { + assert_eq!(purpose("English Description"), LabelPurpose::Descriptive); + assert_eq!(purpose("Descriptions"), LabelPurpose::Normal); + } + #[test] fn iso639_1_accepts_jw_and_mo_dvd_aliases() { assert_eq!(iso639_1_to_iso639_2("jw"), Some("jav")); From d8c6ea7fb1c0b1fddfad5e03841834bad63c6b1d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:09:19 -0700 Subject: [PATCH 3/4] labels/deluxe: fetch the binding class by name, escape disc text, test confidence and union bounds --- src/labels/deluxe.rs | 243 ++++++++++++++++++++++++++++++++++++------- src/labels/jar.rs | 17 +++ 2 files changed, 224 insertions(+), 36 deletions(-) diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index 6e9cb31a..3459d3e4 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -32,12 +32,19 @@ pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool { } pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { + // One inflate budget for every sweep over every jar this parse opens. + parse_with_budget(reader, udf, jar::PARSE_INFLATE_BUDGET) +} + +fn parse_with_budget( + reader: &mut dyn SectorSource, + udf: &UdfFs, + mut budget: u64, +) -> Option { // Per-studio dispatch signal: Deluxe authored the framework for several // studios, each shipping `/BDMV/JAR//config.xml` naming the studio. // Matching below is studio-agnostic, so this is informational only (logged). let studio = detect_studio(reader, udf); - // One inflate budget for every sweep over every jar this parse opens. - let mut budget = jar::PARSE_INFLATE_BUDGET; jar::for_each_jar(reader, udf, |entry_name, archive| { if !jar::has_path_prefix(archive, "com/bydeluxe/") { @@ -364,10 +371,11 @@ fn identify_master_enums( let matches_fp = FINGERPRINTS.iter().any(|fp| fp_matches(fp, &ldcs)); if pool.insert(key, ldcs) { if matches_fp && !pool.insert_fields(key, clinit_enum_field_names(class)) { - tracing::debug!( + tracing::warn!( class = ?key, bytes = pool.bytes, - "deluxe: candidate pool cap hit, enum field names dropped" + "deluxe: candidate pool cap hit, enum field names dropped; \ + getstatic references to this enum may not resolve" ); } } else { @@ -671,7 +679,7 @@ pub(crate) struct Decoded { pub drift: usize, } -/// Phase D entry point: find the binding class in `archive` and run the +/// Phase D entry point: fetch the named binding class from `archive` and run the /// bytecode walker against its ``. fn decode_binding( archive: &mut jar::Jar, @@ -679,14 +687,9 @@ fn decode_binding( master: &MasterEnumTable, budget: &mut u64, ) -> Decoded { - let target_name = binding_class_name.to_string(); - // Short-circuit on the name match: the sweep stops iterating (and - // decompressing/parsing remaining .class entries) once the closure - // returns Some, instead of walking the whole jar past the target. - jar::try_each_class_budgeted(archive, budget, |class_name, class| { - if class_name != target_name { - return None; - } + // Fetched by entry name: the classes ahead of it in the jar are not inflated or + // parsed, so they do not spend the shared budget. + jar::try_class_budgeted(archive, binding_class_name, budget, |class| { Some(decode_binding_class(class, master)) }) .unwrap_or_default() @@ -1224,7 +1227,7 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - let codec_hint = coding_type .as_deref() .map(coding_type_to_codec_hint) - .map(str::to_string) + .map(neutralise) .unwrap_or_default(); // Neither `+= 1` nor `saturating_add` is correct: saturation caused a @@ -1254,7 +1257,7 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - // Resolve language ordinal → enum value string via master // table; then route through vocab::lang for ISO code + variant. - let lang_value = master.value("Language", lang_ord).unwrap_or("").to_string(); + let lang_value = neutralise(master.value("Language", lang_ord).unwrap_or("")); let (language, variant) = match vocab::lang(&lang_value) { Some(li) => (li.code.to_string(), li.variant.to_string()), None if !lang_value.is_empty() => (lang_value.clone(), String::new()), @@ -1297,6 +1300,16 @@ fn interpret_streams(constructions: &[Construction], master: &MasterEnumTable) - out } +// Disc-authored text reaches the label list and from there track names and CLI output: +// control characters are escaped, everything else is kept as authored. +fn neutralise(s: &str) -> String { + if s.chars().any(char::is_control) { + s.escape_debug().to_string() + } else { + s.to_string() + } +} + // Which stream list each binding type enumerates, learned from the constructions that DID // resolve a language; a type that resolved as both kinds is left out (no consistent answer). fn slot_kinds(constructions: &[Construction]) -> HashMap<&str, Option> { @@ -2035,6 +2048,31 @@ mod tests { // ── Phase D: decode_binding (Jar-level short-circuit wrapper) ─────────── + // Classes ahead of the binding class in the jar are not inflated, so they cannot + // spend the budget the walk needs for the binding class itself. + #[test] + fn decode_binding_does_not_charge_classes_ahead_of_the_target() { + let target = class_with_simple_construction("Ignored"); + let need = target.len() as u64; + let decoy = class_with_getstatic_refs("Ignored2", "LanguageEnum", 40); + assert!(decoy.len() as u64 > need); + let zip = build_zip(&[ + ("com/bydeluxe/Decoy.class", decoy), + ("com/bydeluxe/Target.class", target), + ]); + let mut archive = open_jar(zip); + let mut budget = need; + let ctors = decode_binding( + &mut archive, + "com/bydeluxe/Target.class", + &lang_enum_master(), + &mut budget, + ) + .constructions; + assert_eq!(ctors.len(), 1); + assert_eq!(budget, 0); + } + #[test] fn decode_binding_finds_named_class_and_stops_at_first_match() { // Matches by Jar entry path, not `this_class` name. Target path has a @@ -3118,6 +3156,13 @@ mod tests { // Two-class Deluxe jar on a disc: a 70-value Language enum and a binding class // of four `new Slot(Lang.English)`, each preceded by `extra` bytecode. fn deluxe_disc(extra: &[u8]) -> (crate::udf::fixture::MemDisc, crate::udf::UdfFs) { + deluxe_disc_with(&[(extra, 4)]) + } + + // As `deluxe_disc`, over one binding class per `(extra bytecode, binding count)`. + fn deluxe_disc_with( + binds: &[(&[u8], usize)], + ) -> (crate::udf::fixture::MemDisc, crate::udf::UdfFs) { use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir}; let mut values = vec!["English", "French", "Spanish", "Dutch"]; let rest: Vec = (4..70).map(|i| format!("L{i}")).collect(); @@ -3155,28 +3200,38 @@ mod tests { CpInfo::Utf8("com/bydeluxe/Bind".into()), CpInfo::Class { name_index: 16 }, ]; - let mut code = Vec::new(); - for _ in 0..4 { - code.extend([NEW, 0, 11, DUP]); - code.extend_from_slice(extra); - code.extend([GETSTATIC, 0, 9, INVOKESPECIAL, 0, 15, POP]); + let bind_class = |extra: &[u8], count: usize| { + let mut code = Vec::new(); + for _ in 0..count { + code.extend([NEW, 0, 11, DUP]); + code.extend_from_slice(extra); + code.extend([GETSTATIC, 0, 9, INVOKESPECIAL, 0, 15, POP]); + } + code.push(RETURN); + encode_class( + &cp, + 17, + &[MethodSpec { + name_index: 1, + descriptor_index: 2, + code_attr_name_index: 3, + max_stack: 4, + code, + }], + ) + }; + let mut entries = vec![("com/bydeluxe/Lang.class".to_string(), lang)]; + for (i, (extra, count)) in binds.iter().enumerate() { + entries.push(( + format!("com/bydeluxe/Bind{i}.class"), + bind_class(extra, *count), + )); } - code.push(RETURN); - let bind = encode_class( - &cp, - 17, - &[MethodSpec { - name_index: 1, - descriptor_index: 2, - code_attr_name_index: 3, - max_stack: 4, - code, - }], - ); - let jar = build_zip(&[ - ("com/bydeluxe/Lang.class", lang), - ("com/bydeluxe/Bind.class", bind), - ]); + let entries: Vec<(&str, Vec)> = entries + .iter() + .map(|(n, b)| (n.as_str(), b.clone())) + .collect(); + let jar = build_zip(&entries); let dir = |name: &str, icb, files, subdirs| DirSpec { name: name.to_string(), icb_lba: icb, @@ -3215,6 +3270,122 @@ mod tests { assert_eq!(r.confidence, super::super::Confidence::Low); } + // The inflate budget decides confidence: the smallest budget that still completes + // the parse spends it to zero, so the result is not High; one more byte is. + #[test] + fn a_spent_inflate_budget_is_not_high_confidence() { + let (mut disc, udf) = deluxe_disc(&[]); + let (mut lo, mut hi) = (0u64, 1u64 << 20); + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if parse_with_budget(&mut disc, &udf, mid).is_some() { + hi = mid; + } else { + lo = mid; + } + } + let tight = parse_with_budget(&mut disc, &udf, hi).expect("completes at the minimum"); + assert_eq!(tight.confidence, super::super::Confidence::Low); + let roomy = parse_with_budget(&mut disc, &udf, hi + 1).expect("completes"); + assert_eq!(roomy.confidence, super::super::Confidence::High); + } + + // Drift in any binding class downgrades the union, whichever class carries it. + #[test] + fn drift_in_any_binding_class_is_not_high_confidence() { + let drifty: &[u8] = &[0xA8, 0, 3]; + for binds in [[(&[][..], 4), (drifty, 4)], [(drifty, 4), (&[][..], 4)]] { + let (mut disc, udf) = deluxe_disc_with(&binds); + let r = parse(&mut disc, &udf).expect("deluxe labels"); + assert_eq!(r.confidence, super::super::Confidence::Low); + } + } + + // The union across binding classes is bounded by the same cap as each walk. + #[test] + fn constructions_across_binding_classes_are_capped() { + let half = MAX_CONSTRUCTIONS / 2 + 100; + let (mut disc, udf) = deluxe_disc_with(&[(&[], half), (&[], half)]); + let r = parse(&mut disc, &udf).expect("deluxe labels"); + assert_eq!(r.labels.len(), MAX_CONSTRUCTIONS); + } + + // A control character in a CodingType name or language value never reaches the + // label text raw. + #[test] + fn disc_authored_control_characters_are_escaped_in_labels() { + let master = MasterEnumTable::from(&[( + "Language", + MasterEnum { + class_name: "LanguageEnum".into(), + values: vec!["Xx\x1b[31mYy".into()], + fields: Vec::new(), + }, + )]); + let constructions = vec![Construction { + binding_type: "ng".into(), + args: vec![ + StackVal::EnumRef { + kind: "Language", + ordinal: 0, + }, + StackVal::CodingType("\x1b]0;pwn\x07ATMOS_AUDIO".into()), + ], + }]; + let out = interpret_streams(&constructions, &master); + assert_eq!(out.len(), 1); + for text in [&out[0].codec_hint, &out[0].language, &out[0].name] { + assert!(!text.chars().any(char::is_control), "{text:?}"); + } + assert!(out[0].codec_hint.contains("ATMOS_AUDIO")); + } + + // An "English SDH" Language value carries the qualifier the Purpose enum left unset. + #[test] + fn language_value_supplies_the_qualifier_when_purpose_does_not() { + let master = MasterEnumTable::from(&[( + "Language", + MasterEnum { + class_name: "LanguageEnum".into(), + values: vec!["English SDH".into(), "English RNIB".into()], + fields: Vec::new(), + }, + )]); + let slot = |ord: u16| Construction { + binding_type: "SubtitleSlot".into(), + args: vec![StackVal::EnumRef { + kind: "Language", + ordinal: ord, + }], + }; + let out = interpret_streams(&[slot(0), slot(1)], &master); + assert_eq!(out[0].qualifier, LabelQualifier::Sdh); + assert_eq!(out[1].qualifier, LabelQualifier::DescriptiveService); + } + + // The documented Purpose ordinal mapping, every row: purpose and qualifier. + #[test] + fn deluxe_purpose_every_ordinal_maps_as_documented() { + use LabelPurpose::*; + for (ord, want) in [ + (0, Normal), + (1, Commentary), + (2, Normal), + (3, Normal), + (4, Descriptive), + (5, Score), + (6, Normal), + (7, Descriptive), + (8, Normal), + ] { + assert_eq!( + deluxe_purpose_to_label(ord), + (want, LabelQualifier::None), + "ordinal {ord}" + ); + } + } + // ── interpret_streams + deluxe_purpose_to_label tests ─────────────────── #[test] diff --git a/src/labels/jar.rs b/src/labels/jar.rs index c0382526..6b8761f5 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -383,6 +383,23 @@ where }) } +/// Parse the one `.class` entry named `entry_name` (its full jar path) and call `f` on it. +/// Every other entry is skipped by name, so it is neither inflated nor charged to `budget`. +pub fn try_class_budgeted( + archive: &mut ZipArchive, + entry_name: &str, + budget: &mut u64, + mut f: F, +) -> Option +where + F: FnMut(&ClassFile) -> Option, +{ + let is_target = |n: &str| n == entry_name; + try_each_entry(archive, is_target, MAX_CLASS_BYTES, budget, |_, bytes| { + f(&ClassFile::parse(bytes).ok()?) + }) +} + /// [`try_each_class_budgeted`] visiting every class (no short-circuit). pub fn for_each_class_budgeted( archive: &mut ZipArchive, From 38b302fc86afe2b346ea9c3c322ef8e9ebe613ec Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:11:16 -0700 Subject: [PATCH 4/4] lint_stop: drop the ctrm items_after_test_module baseline entry --- tests/lint_stop.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/lint_stop.rs b/tests/lint_stop.rs index a6bae9a3..7a94b102 100644 --- a/tests/lint_stop.rs +++ b/tests/lint_stop.rs @@ -583,11 +583,6 @@ const ALLOW_BASELINE: &[(&str, &str, usize)] = &[ r#"expect(dead_code,reason="payloadsarefaultdetailforDebug;callersmatchthevariant")"#, 1, ), - ( - "src/labels/ctrm.rs", - r#"allow(clippy::items_after_test_module)"#, - 1, - ), ("src/mpls.rs", r#"allow(dead_code)"#, 2), ("src/mux/demux_sink.rs", r#"allow(dead_code)"#, 2), ("src/mux/demux_thread.rs", r#"allow(dead_code)"#, 1),