mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Merge branch 'audit-lib-G4' into audit-fix-lib
This commit is contained in:
+115
-20
@@ -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
@@ -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 `& < > " ' &#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 & Jerry 's A</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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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/>"[..]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user