Merge branch 'audit-lib-G4' into audit-fix-lib

This commit is contained in:
Matthew Jackson
2026-10-01 15:12:45 -07:00
16 changed files with 1467 additions and 227 deletions
+115 -20
View File
@@ -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<u8> {
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<u8> {
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<u32>);
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<u8> {
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<u8> {
fn app_string(buf: &mut Vec<u8>, 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
}
}
+94 -13
View File
@@ -43,8 +43,8 @@ pub struct DiscMetadata {
pub titles: BTreeMap<String, String>,
/// First-line / short description, per lang
pub descriptions: BTreeMap<String, String>,
/// Disc N of M, returned whenever both `<di:discNumber>` and
/// `<di:numSets>` are present and sane (including `(1, 1)` for a
/// Disc N of M, returned whenever both `<di:setNumber>` (or the
/// `<di:discNumber>` fallback) and `<di:numSets>` 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<String>, Option<(u32, u32)>);
// could be located.
pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> {
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<String> {
let raw = raw.trim();
let out = match raw
.strip_prefix("<![CDATA[")
.and_then(|r| r.strip_suffix("]]>"))
{
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 `&amp; &lt; &gt; &quot; &apos; &#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<String> {
// 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<String> {
// don't accidentally pick a stray <titleName> 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 = "<d><di:name>Tom &amp; Jerry &#39;s &#x41;</di:name>\
<di:description><![CDATA[a < b & c]]></di:description></d>";
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 = "<d><di:name><![CDATA[Real]]></di:name></d>";
assert_eq!(parse_bdmt_xml(xml).expect("parse").0, "Real");
}
#[test]
fn toc_title_name_with_markup_is_rejected() {
let xml = "<d><tableOfContents><titleName>Real <di:thumbnail/></titleName>\
</tableOfContents></d>";
assert_eq!(parse_bdmt_xml(xml), None);
}
#[test]
fn extract_simple_title() {
// Minimal Paramount-style document: <di:name> as the title
+18
View File
@@ -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
+69
View File
@@ -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<u8>, mpls: Vec<u8>) -> 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);
}
}
+18
View File
@@ -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 = "<AudioStreamInfos><ID>a0</ID><LangInfoID>ENG</LangInfoID></AudioStreamInfos>\
<AudioStreamInfos><ID>a1</ID><LangInfoID>FRA</LangInfoID></AudioStreamInfos>";
let pc = "<AudioStreams><StreamID>2</StreamID><StreamInfo_ID>a0</StreamInfo_ID></AudioStreams>\
<AudioStreams><StreamID>2</StreamID><StreamInfo_ID>a1</StreamInfo_ID></AudioStreams>";
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
+173 -128
View File
@@ -226,10 +226,130 @@ fn parse_language_streams_text(text: &str) -> Vec<StreamLabel> {
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<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)
}
// 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<StreamLabel> {
// Parse key=value, group by prefix
// BTreeMap: deterministic prefix order so equal (type, number) ties sort stably.
let mut entries: BTreeMap<String, HashMap<String, String>> = 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<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)
}
// 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<StreamLabel> {
// Parse key=value, group by prefix
// BTreeMap: deterministic prefix order so equal (type, number) ties sort stably.
let mut entries: BTreeMap<String, HashMap<String, String>> = 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
}
+14
View File
@@ -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();
+207 -36
View File
@@ -32,12 +32,19 @@ pub fn detect(reader: &mut dyn SectorSource, udf: &UdfFs) -> bool {
}
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
// 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<ParseResult> {
// Per-studio dispatch signal: Deluxe authored the framework for several
// studios, each shipping `/BDMV/JAR/<n>/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 `<clinit>`.
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<StreamLabelType>> {
@@ -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<String> = (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<u8>)> = 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]
+39
View File
@@ -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#"<dcx><disc>
<playlist id="00800" name="feature" durs="7000"><audio id="01" lang="eng"/></playlist>
<playlist id="00900" name="feature" durs="7000"><audio id="01" lang="eng"/></playlist>
</disc></dcx>"#;
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#"<dcx><disc>
<playlist id="00800" name="feature" durs="9000"><audio id="01" lang="eng"/></playlist>
<playlist id="00900" name="feature" durs="7000">
<audio id="01" lang="eng"/><audio id="02" lang="fra"/>
</playlist>
</disc></dcx>"#;
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#"<dcx><disc>
<playlist id="00800" name="FEATURE" durs="7000">
<audio id="01" lang="ENG" type="RNIB"/>
<subtitle id="01" lang="ENG" type="Feature" form="SDH"/>
<subtitle id="02" lang="FRA" type="EMBED"/>
</playlist>
</disc></dcx>"#;
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]
+64
View File
@@ -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<Z: Read + Seek, R, F>(
archive: &mut ZipArchive<Z>,
entry_name: &str,
budget: &mut u64,
mut f: F,
) -> Option<R>
where
F: FnMut(&ClassFile) -> Option<R>,
{
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<Z: Read + Seek, F>(
archive: &mut ZipArchive<Z>,
@@ -710,6 +727,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"<a/>").unwrap();
w.finish().unwrap();
}
let mut jar = open(buf);
let mut seen: Vec<String> = 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() {
+373 -21
View File
@@ -302,8 +302,16 @@ pub fn apply(
titles: &mut [DiscTitle],
) -> Option<FeaturePlaylistHint> {
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::<String>().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<u8> {
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<FileSpec>, subdirs: Vec<DirSpec>) -> 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#"<dcx><disc>
<playlist id="00800" name="feature" durs="7000"><audio id="01" lang="eng"/></playlist>
</disc></dcx>"#;
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"<p/>".to_vec(),
true,
)],
vec![],
),
],
)],
)],
));
let got = read_jar_file(&mut disc, &udf, "playlists.xml");
assert_eq!(got.as_deref(), Some(&b"<p/>"[..]));
}
}
+114 -4
View File
@@ -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<ParseResult>
let mut labels: Vec<StreamLabel> = Vec::new();
let mut seen: HashSet<super::StreamId> = 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<StreamLabelType> {
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<u8> {
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<Playlist> = (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.
+54
View File
@@ -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<u16> {
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#"
<playlist name="Feature" id="00001" duration="600" aud="eng"/>
<playlist name="Feature" id="00002" duration="7000" aud="eng"/>
"#;
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#"
<playlist name="Feature" id="00800"/>
<playlist name="Bonus" id="00900" aud="eng,fra"/>
"#;
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#"
<playlist name="Menu" id="00001" duration="600" aud="eng,fra"/>
<playlist name="Other" id="00002" duration="7000" aud="eng,fra"/>
"#;
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#"<playlist name="Feature" id="00800" duration="{secs}"/>"#);
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#"<playlist name="Feature" id="00001" {key}="10"/>
<playlist name="Feature" id="00800" {key}="7000"/>"#
);
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() {
+49
View File
@@ -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::<Vec<_>>(), ["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<ParseResult> {
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);
}
}
+66
View File
@@ -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"));
-5
View File
@@ -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),