test: salvage the orphaned labels/disc triage, and extract build_labels
Thirteen agents triaging src/labels and src/disc died on a saturated
machine, leaving 5,836 insertions across 28 files uncommitted in a
worktree. Recovered by 3-way apply onto twelve commits of drift; zero
conflicts. The diff was archived to freemkv-private first, because a
worktree is not a backup and this one had already nearly been lost.
One production change, and it is the right one: mpls_universal::parse
read every playlist off the disc AND converted the entries to labels in
a single function, so the conversion — stream-type mapping, dedup key,
the dense global counters — could only be reached through a synthetic
UDF image. Extracted to build_labels(&[Playlist]), which unit tests can
drive from already-parsed values. Behaviour-preserving: same iteration
order, same skip-on-error.
Two collisions resolved by hand:
A second mod pass_progress_tests, written independently against the
same survivors as the one committed in c610285. Kept mine — it covers
the distinct-counters case and the Progress blanket impl, which theirs
does not — but theirs had three clamp tests mine lacked: good_pct,
bad_pct and pending_pct also clamp an overshoot, and I had only tested
that for work_pct. Merged those in as one test and proved each of the
three clamps load-bearing by removing them individually.
An unused_parens warning in a new fixture.
Method note, recorded because it cost real time: git apply --3way
STAGES its result, so `git diff` reads empty and the tree looks
untouched. I nearly concluded the patch had silently failed. Worse, the
first attempt piped through `head -20`, so `echo exit=$?` reported
head's status rather than git's — the same mistake this audit has
already documented once. Check the real exit status, and check
--cached, not just the working tree.
This commit is contained in:
@@ -566,4 +566,33 @@ mod tests {
|
||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(title, "Real Title");
|
||||
}
|
||||
|
||||
/// `is_bdmt_filename` must recognize the `bdmt_<lang>.xml` convention
|
||||
/// and reject everything else — it drives `detect`'s directory scan.
|
||||
/// Mutation: stub the return to a constant `true`/`false` → every
|
||||
/// directory listing (or none) would match regardless of filename.
|
||||
#[test]
|
||||
fn is_bdmt_filename_matches_convention_only() {
|
||||
assert!(is_bdmt_filename("bdmt_eng.xml"));
|
||||
assert!(is_bdmt_filename("BDMT_FRA.XML"));
|
||||
assert!(!is_bdmt_filename("bdmt_engl.xml"));
|
||||
assert!(!is_bdmt_filename("index.bdmv"));
|
||||
assert!(!is_bdmt_filename("foo.xml"));
|
||||
}
|
||||
|
||||
/// Spec: "Disc 1 of 1" (a single-disc release whose bdmt XML still
|
||||
/// carries `<di:numSets>1</di:numSets>`) is a valid, non-nonsensical
|
||||
/// pair — `total < 1` must reject only `total == 0`, not `total == 1`.
|
||||
/// Mutation: `total < 1` -> `total == 1` or `total <= 1` would reject
|
||||
/// this legitimate (1, 1) pair as if it were malformed.
|
||||
#[test]
|
||||
fn disc_set_allows_single_disc_release() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Film</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
<di:numSets>1</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||
assert_eq!(set, Some((1, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,4 +1388,162 @@ mod tests {
|
||||
let _ = decode_modified_utf8(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// ConstantPool / ClassFile accessor correctness
|
||||
//
|
||||
// These exercise plain data accessors on an already-parsed pool
|
||||
// (built via the test-only `from_entries` constructor) — not the
|
||||
// untrusted-bytes parsing path, just "does the right variant map to
|
||||
// the right Option value."
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
fn sample_pool() -> ConstantPool {
|
||||
// index: 0=Empty (reserved), 1=Utf8("Hello"), 2=Integer(42),
|
||||
// 3=String{string_index:1}, 4=Class{name_index:1}, 5=Float(1.5),
|
||||
// 6=Long(9), 7=Empty (2-slot tail), 8=Double(2.5), 9=Empty (tail).
|
||||
ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("Hello".to_string()),
|
||||
CpInfo::Integer(42),
|
||||
CpInfo::String { string_index: 1 },
|
||||
CpInfo::Class { name_index: 1 },
|
||||
CpInfo::Float(1.5),
|
||||
CpInfo::Long(9),
|
||||
CpInfo::Empty,
|
||||
CpInfo::Double(2.5),
|
||||
CpInfo::Empty,
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_string_resolves_through_string_index() {
|
||||
let pool = sample_pool();
|
||||
// index 3 is CpInfo::String{string_index: 1} -> utf8(1) = "Hello".
|
||||
assert_eq!(pool.string(3), Some("Hello"));
|
||||
// Wrong variant (Integer at index 2) must not resolve as a string.
|
||||
assert_eq!(pool.string(2), None);
|
||||
// Out of range index.
|
||||
assert_eq!(pool.string(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_integer_resolves_only_integer_entries() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(pool.integer(2), Some(42));
|
||||
// Wrong variant (Utf8 at index 1) must not resolve as an integer.
|
||||
assert_eq!(pool.integer(1), None);
|
||||
assert_eq!(pool.integer(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_load_constant_display_covers_ldc_operand_kinds() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(
|
||||
pool.load_constant_display(1),
|
||||
Some("utf8:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(pool.load_constant_display(2), Some("int:42".to_string()));
|
||||
assert_eq!(
|
||||
pool.load_constant_display(3),
|
||||
Some("str:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
pool.load_constant_display(4),
|
||||
Some("class:\"Hello\"".to_string())
|
||||
);
|
||||
assert_eq!(pool.load_constant_display(5), Some("float:1.5".to_string()));
|
||||
assert_eq!(pool.load_constant_display(6), Some("long:9".to_string()));
|
||||
assert_eq!(
|
||||
pool.load_constant_display(8),
|
||||
Some("double:2.5".to_string())
|
||||
);
|
||||
// A variant with no display arm (e.g. reserved Empty slot) -> None.
|
||||
assert_eq!(pool.load_constant_display(0), None);
|
||||
assert_eq!(pool.load_constant_display(999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_len_and_is_empty() {
|
||||
let pool = sample_pool();
|
||||
assert_eq!(pool.len(), 10);
|
||||
assert!(!pool.is_empty());
|
||||
|
||||
let empty = ConstantPool::from_entries(vec![]);
|
||||
assert_eq!(empty.len(), 0);
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_pool_iter_yields_index_and_entry_pairs() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("A".to_string()),
|
||||
CpInfo::Integer(7),
|
||||
]);
|
||||
let indices: Vec<u16> = pool.iter().map(|(i, _)| i).collect();
|
||||
assert_eq!(indices, vec![0, 1, 2]);
|
||||
// Confirm the entries themselves come through, not an empty iterator.
|
||||
let utf8_at_1 = pool.iter().find(|(i, _)| *i == 1).map(|(_, e)| match e {
|
||||
CpInfo::Utf8(s) => s.as_str(),
|
||||
_ => "?",
|
||||
});
|
||||
assert_eq!(utf8_at_1, Some("A"));
|
||||
}
|
||||
|
||||
fn class_file_with(this_class: u16, super_class: u16, pool: ConstantPool) -> ClassFile {
|
||||
ClassFile {
|
||||
minor_version: 0,
|
||||
major_version: 0,
|
||||
constant_pool: pool,
|
||||
access_flags: 0,
|
||||
this_class,
|
||||
super_class,
|
||||
interfaces: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
methods: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_class_name_and_super_class_name_resolve_distinct_indices() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("com/example/Foo".to_string()),
|
||||
CpInfo::Utf8("com/example/Bar".to_string()),
|
||||
CpInfo::Class { name_index: 1 },
|
||||
CpInfo::Class { name_index: 2 },
|
||||
]);
|
||||
let cf = class_file_with(3, 4, pool);
|
||||
assert_eq!(cf.this_class_name(), Some("com/example/Foo"));
|
||||
assert_eq!(cf.super_class_name(), Some("com/example/Bar"));
|
||||
|
||||
// this_class index pointing at a non-Class entry must not resolve.
|
||||
let pool2 = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("not a class ref".to_string()),
|
||||
]);
|
||||
let cf2 = class_file_with(1, 1, pool2);
|
||||
assert_eq!(cf2.this_class_name(), None);
|
||||
assert_eq!(cf2.super_class_name(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_descriptor_resolves_the_descriptor_not_the_name() {
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("doStuff".to_string()), // index 1: name
|
||||
CpInfo::Utf8("()V".to_string()), // index 2: descriptor
|
||||
]);
|
||||
let cf = class_file_with(0, 0, pool);
|
||||
let m = Member {
|
||||
access_flags: 0,
|
||||
name_index: 1,
|
||||
descriptor_index: 2,
|
||||
attributes: Vec::new(),
|
||||
};
|
||||
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
||||
assert_ne!(cf.member_descriptor(&m), Some("doStuff"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,68 @@ mod tests {
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: `menu_base.prop` lines are skipped when `is_empty() ||
|
||||
/// starts_with('#')` — either alone is sufficient. A commented-out
|
||||
/// key=value line must never be parsed into an entry.
|
||||
/// Mutation: `||` -> `&&` requires both, which a non-empty comment
|
||||
/// line can't satisfy, so it falls through to `line.find('=')` and
|
||||
/// gets parsed as a real property.
|
||||
#[test]
|
||||
fn menu_base_comment_line_with_equals_is_still_skipped() {
|
||||
let labels = parse_props(
|
||||
"#audio_1.class=AudioButton\n\
|
||||
#audio_1.streamNumber=9\n\
|
||||
#audio_1.name=Should Not Appear\n\
|
||||
audio_2.class=AudioButton\n\
|
||||
audio_2.streamNumber=1\n\
|
||||
audio_2.name=Real Track\n",
|
||||
);
|
||||
assert_eq!(labels.len(), 1, "commented-out entry must not be parsed");
|
||||
assert_eq!(labels[0].name, "Real Track");
|
||||
}
|
||||
|
||||
/// Spec: `menu_base.prop` streamNumber (or audioStream/subtitleStream)
|
||||
/// must be strictly positive — `0` means "no STN entry" and must be
|
||||
/// skipped, matching the `n > 0` guard on the language_streams side.
|
||||
/// Mutation: `n > 0` -> `n >= 0` (or the guard deleted) would let a
|
||||
/// stream_num of 0 through, emitting a dead label apply_labels can
|
||||
/// never match (its counter starts at 1).
|
||||
#[test]
|
||||
fn menu_base_zero_stream_number_skipped() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=0\n\
|
||||
audio_1.name=Disabled Slot\n",
|
||||
);
|
||||
assert!(
|
||||
labels.is_empty(),
|
||||
"streamNumber=0 must be skipped, got {labels:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: `is_subtitle` is `class.contains("SubtitleButton") ||
|
||||
/// prefix.starts_with("subtitle_")` — EITHER signal alone is
|
||||
/// sufficient to classify (and keep) a subtitle entry whose prefix
|
||||
/// doesn't follow the `subtitle_` naming convention.
|
||||
/// Mutation: `||` -> `&&` would require BOTH signals; an entry whose
|
||||
/// class says SubtitleButton but whose prefix is something else
|
||||
/// (e.g. a vendor-specific button id) would then satisfy neither
|
||||
/// `is_audio` nor `is_subtitle` and get dropped entirely.
|
||||
#[test]
|
||||
fn menu_base_subtitle_class_alone_is_sufficient() {
|
||||
let labels = parse_props(
|
||||
"menuBtn7.class=SubtitleButton\n\
|
||||
menuBtn7.streamNumber=1\n\
|
||||
menuBtn7.name=English SDH\n",
|
||||
);
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
1,
|
||||
"class=SubtitleButton alone must classify as subtitle, not be dropped"
|
||||
);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_commentary_segment_match_not_substring() {
|
||||
// Genuine commentary group segments match.
|
||||
@@ -350,6 +412,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec: `merge`'s `mb.iter().find(...)` must match an mb entry by
|
||||
/// (stream_type AND stream_number) TOGETHER — either alone is not a
|
||||
/// unique key (there can be an audio #1 and a subtitle #1, or two
|
||||
/// different audio streams).
|
||||
/// Mutation: `&&` -> `||` inside the closure would match on type OR
|
||||
/// number alone, so `.find` (which returns the FIRST match) can pick
|
||||
/// an mb entry with the right type but the WRONG stream number.
|
||||
#[test]
|
||||
fn merge_matches_mb_entry_by_type_and_number_together() {
|
||||
// ls wants audio #2 (empty name, so it will borrow from mb).
|
||||
let ls = vec![lbl(StreamLabelType::Audio, 2, "")];
|
||||
// mb's FIRST audio entry is #1 (wrong number); its #2 entry (the
|
||||
// real match) comes second.
|
||||
let mb = vec![
|
||||
lbl(StreamLabelType::Audio, 1, "Wrong Number Match"),
|
||||
lbl(StreamLabelType::Audio, 2, "Correct Match"),
|
||||
];
|
||||
let merged = merge(ls, mb);
|
||||
assert_eq!(
|
||||
merged.len(),
|
||||
2,
|
||||
"mb's own audio #1 must also survive as its own entry"
|
||||
);
|
||||
let a2 = merged
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 2)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
a2.name, "Correct Match",
|
||||
"must match mb by (type AND number), not type or number alone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_menu_base_only_streams() {
|
||||
// language_streams covers audio 1; menu_base has audio 1 (name)
|
||||
@@ -506,6 +601,60 @@ mod tests {
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
}
|
||||
|
||||
/// Spec: the skip test is `is_empty() || starts_with('#')` — EITHER
|
||||
/// condition alone must skip the line. A commented-out line that
|
||||
/// happens to look like valid CSV (a real authoring pattern for
|
||||
/// disabling a stream entry) must never produce a label.
|
||||
/// Mutation: `||` -> `&&` requires BOTH conditions, which a non-empty
|
||||
/// comment line can never satisfy, so it would fall through to the
|
||||
/// CSV parser and (since it has >= 4 comma fields) emit a spurious
|
||||
/// label instead of being skipped.
|
||||
#[test]
|
||||
fn ls_comment_line_with_csv_shape_is_still_skipped() {
|
||||
let labels =
|
||||
parse_language_streams_text("#id,audio_production,1,eng\nid2,audio_production,2,fra\n");
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
1,
|
||||
"the commented-out CSV-shaped line must not parse"
|
||||
);
|
||||
assert_eq!(labels[0].language, "fra");
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_dual` is a recognized subtitle type (Normal/no
|
||||
/// qualifier). Mutation: delete this match arm → falls to the
|
||||
/// catch-all `_ => continue`, silently dropping the stream.
|
||||
#[test]
|
||||
fn ls_subtitle_dual_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_dual,1,eng\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_dual must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_bonus` is a recognized subtitle type (Normal/no
|
||||
/// qualifier). Mutation: delete this match arm → dropped as unknown.
|
||||
#[test]
|
||||
fn ls_subtitle_bonus_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_bonus,2,eng\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_bonus must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
/// Spec: `subtitle_ime` maps to Subtitle/Ime (no Forced qualifier,
|
||||
/// unlike `subtitle_ime_narrative`).
|
||||
/// Mutation: delete this match arm → dropped as unknown.
|
||||
#[test]
|
||||
fn ls_subtitle_ime_parsed() {
|
||||
let labels = parse_language_streams_text("id,subtitle_ime,3,jpn\n");
|
||||
assert_eq!(labels.len(), 1, "subtitle_ime must produce a label");
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Ime);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
|
||||
/// Spec: multiple valid lines produce multiple labels.
|
||||
/// Mutation: stop after first label → only 1 label returned.
|
||||
#[test]
|
||||
|
||||
@@ -186,6 +186,91 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
|
||||
mod tests {
|
||||
use super::super::{LabelPurpose, LabelQualifier};
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write as _};
|
||||
|
||||
/// Build a minimal, structurally valid `.class` file (JVMS §4.1) whose
|
||||
/// constant pool holds exactly the given `Utf8` strings (indices 1..=N,
|
||||
/// no long/double slot padding needed for plain strings). No fields,
|
||||
/// methods, interfaces, or attributes — `scan_jar`'s only interest is
|
||||
/// the constant pool.
|
||||
fn build_class(utf8_entries: &[&str]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); // magic
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // minor_version
|
||||
out.extend_from_slice(&52u16.to_be_bytes()); // major_version (Java 8)
|
||||
out.extend_from_slice(&((utf8_entries.len() + 1) as u16).to_be_bytes()); // cp_count
|
||||
for s in utf8_entries {
|
||||
out.push(1); // CONSTANT_Utf8 tag
|
||||
out.extend_from_slice(&(s.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // access_flags
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // this_class
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // super_class
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // interfaces_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // fields_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // methods_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // attributes_count
|
||||
out
|
||||
}
|
||||
|
||||
/// Zip `entries` (name -> bytes) into an in-memory, Stored (uncompressed)
|
||||
/// `jar::Jar` via the `zip` crate's own writer — a real archive, not a
|
||||
/// hand-rolled central directory.
|
||||
fn build_jar(entries: &[(&str, Vec<u8>)]) -> jar::Jar {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in entries {
|
||||
writer.start_file(*name, opts).expect("start_file");
|
||||
writer.write_all(&data[..]).expect("write class bytes");
|
||||
}
|
||||
writer.finish().expect("finish zip");
|
||||
}
|
||||
zip::ZipArchive::new(Cursor::new(buf)).expect("valid zip")
|
||||
}
|
||||
|
||||
/// `scan_jar` wires together `for_each_class`, constant-pool iteration,
|
||||
/// `collect_textfield`, and `make_label` into the actual per-jar scan
|
||||
/// used by `parse`. The pure `collect_textfield`/`make_label` unit
|
||||
/// tests above don't exercise this wiring at all.
|
||||
///
|
||||
/// Mutation: replace the whole function body with `vec![]` — every
|
||||
/// dbp disc would silently lose all its stream labels regardless of
|
||||
/// what's in the jar.
|
||||
#[test]
|
||||
fn scan_jar_extracts_labels_from_real_class_entries() {
|
||||
let class_bytes = build_class(&[
|
||||
"com/dbp/Whatever", // unrelated string — must be ignored
|
||||
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763",
|
||||
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763",
|
||||
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843", // disable button, skipped
|
||||
]);
|
||||
let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]);
|
||||
|
||||
let labels = scan_jar(&mut archive);
|
||||
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
2,
|
||||
"expected one audio + one real subtitle label"
|
||||
);
|
||||
let audio = labels
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.expect("audio label present");
|
||||
assert_eq!(audio.stream_number, 1);
|
||||
assert_eq!(audio.language, "eng");
|
||||
|
||||
let sub = labels
|
||||
.iter()
|
||||
.find(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.expect("subtitle label present");
|
||||
assert_eq!(sub.stream_number, 1);
|
||||
assert_eq!(sub.qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
/// A `CONSTANT_Utf8_info` carries a `u16` length (JVMS §4.4.7), so one
|
||||
/// crafted constant contributes up to 65535 bytes and the `u16` stream
|
||||
|
||||
@@ -1106,6 +1106,341 @@ fn deluxe_purpose_to_label(ordinal: u16) -> (LabelPurpose, LabelQualifier) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Raw .class / .jar fixture builders ──────────────────────────────────
|
||||
//
|
||||
// `identify_master_enums`, `find_binding_classes` and `decode_binding`
|
||||
// operate on `jar::Jar` (a real `ZipArchive`), not on the in-memory
|
||||
// `ClassFile` struct the rest of this module's tests build directly (see
|
||||
// `class_with_clinit`). To exercise them we need real serialized
|
||||
// `.class` bytes inside a real (stored, uncompressed) zip — this is the
|
||||
// inverse of `ClassFile::parse` / JVMS §4.
|
||||
|
||||
/// Serialize a constant pool (no Long/Double entries — those need the
|
||||
/// post-slot `Empty` padding this helper doesn't handle) to the on-disk
|
||||
/// `cp_info` sequence, prefixed by `constant_pool_count`.
|
||||
fn encode_cp(entries: &[CpInfo]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(entries.len() as u16).to_be_bytes());
|
||||
for e in &entries[1..] {
|
||||
match e {
|
||||
CpInfo::Utf8(s) => {
|
||||
out.push(1);
|
||||
out.extend_from_slice(&(s.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
CpInfo::Integer(n) => {
|
||||
out.push(3);
|
||||
out.extend_from_slice(&n.to_be_bytes());
|
||||
}
|
||||
CpInfo::Class { name_index } => {
|
||||
out.push(7);
|
||||
out.extend_from_slice(&name_index.to_be_bytes());
|
||||
}
|
||||
CpInfo::String { string_index } => {
|
||||
out.push(8);
|
||||
out.extend_from_slice(&string_index.to_be_bytes());
|
||||
}
|
||||
CpInfo::Fieldref {
|
||||
class_index,
|
||||
name_and_type_index,
|
||||
} => {
|
||||
out.push(9);
|
||||
out.extend_from_slice(&class_index.to_be_bytes());
|
||||
out.extend_from_slice(&name_and_type_index.to_be_bytes());
|
||||
}
|
||||
CpInfo::NameAndType {
|
||||
name_index,
|
||||
descriptor_index,
|
||||
} => {
|
||||
out.push(12);
|
||||
out.extend_from_slice(&name_index.to_be_bytes());
|
||||
out.extend_from_slice(&descriptor_index.to_be_bytes());
|
||||
}
|
||||
CpInfo::Methodref {
|
||||
class_index,
|
||||
name_and_type_index,
|
||||
} => {
|
||||
out.push(10);
|
||||
out.extend_from_slice(&class_index.to_be_bytes());
|
||||
out.extend_from_slice(&name_and_type_index.to_be_bytes());
|
||||
}
|
||||
other => unimplemented!("fixture builder doesn't need {other:?}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One method's worth of `Code` attribute bytecode, keyed by the cp
|
||||
/// index of the `"Code"` Utf8 entry.
|
||||
struct MethodSpec {
|
||||
name_index: u16,
|
||||
descriptor_index: u16,
|
||||
code_attr_name_index: u16,
|
||||
max_stack: u16,
|
||||
code: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Serialize a minimal but real `.class` byte buffer: magic, versions,
|
||||
/// constant pool, an empty interfaces/fields table, the given methods
|
||||
/// (each with exactly one `Code` attribute), and no class attributes.
|
||||
fn encode_class(cp: &[CpInfo], this_class: u16, methods: &[MethodSpec]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // minor
|
||||
out.extend_from_slice(&52u16.to_be_bytes()); // major
|
||||
out.extend_from_slice(&encode_cp(cp));
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // access_flags
|
||||
out.extend_from_slice(&this_class.to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // super_class
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // interfaces_count
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // fields_count
|
||||
out.extend_from_slice(&(methods.len() as u16).to_be_bytes());
|
||||
for m in methods {
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // access_flags
|
||||
out.extend_from_slice(&m.name_index.to_be_bytes());
|
||||
out.extend_from_slice(&m.descriptor_index.to_be_bytes());
|
||||
out.extend_from_slice(&1u16.to_be_bytes()); // attributes_count = 1 (Code)
|
||||
out.extend_from_slice(&m.code_attr_name_index.to_be_bytes());
|
||||
let info_len = 2 + 2 + 4 + m.code.len();
|
||||
out.extend_from_slice(&(info_len as u32).to_be_bytes());
|
||||
out.extend_from_slice(&m.max_stack.to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // max_locals
|
||||
out.extend_from_slice(&(m.code.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&m.code);
|
||||
}
|
||||
out.extend_from_slice(&0u16.to_be_bytes()); // attributes_count (class)
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a raw, multi-entry, Stored (uncompressed) ZIP — same format as
|
||||
/// `jar::tests::build_stored_zip`, generalized to N entries (that helper
|
||||
/// is private to `jar.rs`).
|
||||
fn build_zip(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
fn crc32(payload: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in payload {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
let mut offsets = Vec::new();
|
||||
for (name, payload) in entries {
|
||||
let name_bytes = name.as_bytes();
|
||||
let crc = crc32(payload);
|
||||
offsets.push(out.len() as u32);
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // Stored
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name_bytes);
|
||||
out.extend_from_slice(payload);
|
||||
}
|
||||
for ((name, payload), &lfh_offset) in entries.iter().zip(&offsets) {
|
||||
let name_bytes = name.as_bytes();
|
||||
let crc = crc32(payload);
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes());
|
||||
central.extend_from_slice(&20u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&crc.to_le_bytes());
|
||||
central.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u16.to_le_bytes());
|
||||
central.extend_from_slice(&0u32.to_le_bytes());
|
||||
central.extend_from_slice(&lfh_offset.to_le_bytes());
|
||||
central.extend_from_slice(name_bytes);
|
||||
}
|
||||
let cd_offset = out.len() as u32;
|
||||
let cd_size = central.len() as u32;
|
||||
out.extend_from_slice(¢ral);
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&cd_size.to_le_bytes());
|
||||
out.extend_from_slice(&cd_offset.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn open_jar(bytes: Vec<u8>) -> jar::Jar {
|
||||
jar::Jar::new(std::io::Cursor::new(bytes)).expect("valid zip")
|
||||
}
|
||||
|
||||
/// Build a `.class` fixture whose `<clinit>` does N `ldc` of distinct
|
||||
/// Utf8 constants `values[0..N]` — i.e. a class matching a
|
||||
/// `FINGERPRINTS` shape by ldc-sequence.
|
||||
fn class_with_ldc_strings(class_name: &str, values: &[&str]) -> Vec<u8> {
|
||||
// cp layout: 1 "<clinit>", 2 "()V", 3 "Code", then one Utf8 +
|
||||
// one String per value, in pairs (4,5), (6,7), ...
|
||||
let mut cp = vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("<clinit>".into()),
|
||||
CpInfo::Utf8("()V".into()),
|
||||
CpInfo::Utf8("Code".into()),
|
||||
];
|
||||
let mut code = Vec::new();
|
||||
for v in values {
|
||||
let utf8_idx = cp.len() as u16;
|
||||
cp.push(CpInfo::Utf8((*v).to_string()));
|
||||
let str_idx = cp.len() as u16;
|
||||
cp.push(CpInfo::String {
|
||||
string_index: utf8_idx,
|
||||
});
|
||||
code.push(LDC);
|
||||
code.push(str_idx as u8);
|
||||
}
|
||||
cp.push(CpInfo::Utf8(class_name.to_string()));
|
||||
let this_class_name_idx = (cp.len() - 1) as u16;
|
||||
cp.push(CpInfo::Class {
|
||||
name_index: this_class_name_idx,
|
||||
});
|
||||
let this_class_idx = (cp.len() - 1) as u16;
|
||||
let methods = vec![MethodSpec {
|
||||
name_index: 1,
|
||||
descriptor_index: 2,
|
||||
code_attr_name_index: 3,
|
||||
max_stack: 2,
|
||||
code,
|
||||
}];
|
||||
encode_class(&cp, this_class_idx, &methods)
|
||||
}
|
||||
|
||||
/// Build a `.class` fixture whose `<clinit>` does N `getstatic`
|
||||
/// references to `enum_class.FIELD_i`, for `count_master_enum_getstatic`
|
||||
/// / `find_binding_classes` Jar-level fixtures.
|
||||
fn class_with_getstatic_refs(class_name: &str, enum_class: &str, n: usize) -> Vec<u8> {
|
||||
let mut cp = vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("<clinit>".into()),
|
||||
CpInfo::Utf8("()V".into()),
|
||||
CpInfo::Utf8("Code".into()),
|
||||
CpInfo::Utf8(enum_class.to_string()),
|
||||
];
|
||||
let enum_class_name_idx = 4u16;
|
||||
cp.push(CpInfo::Class {
|
||||
name_index: enum_class_name_idx,
|
||||
});
|
||||
let enum_class_idx = (cp.len() - 1) as u16;
|
||||
cp.push(CpInfo::Utf8("Lsome/Enum;".into()));
|
||||
let descriptor_idx = (cp.len() - 1) as u16;
|
||||
let mut code = Vec::new();
|
||||
for i in 0..n {
|
||||
let field_name_idx = cp.len() as u16;
|
||||
cp.push(CpInfo::Utf8(format!("F{i}")));
|
||||
let nat_idx = cp.len() as u16;
|
||||
cp.push(CpInfo::NameAndType {
|
||||
name_index: field_name_idx,
|
||||
descriptor_index: descriptor_idx,
|
||||
});
|
||||
let fieldref_idx = cp.len() as u16;
|
||||
cp.push(CpInfo::Fieldref {
|
||||
class_index: enum_class_idx,
|
||||
name_and_type_index: nat_idx,
|
||||
});
|
||||
code.push(GETSTATIC);
|
||||
code.extend_from_slice(&fieldref_idx.to_be_bytes());
|
||||
code.push(0x57); // pop, so the symbolic stack doesn't matter here
|
||||
}
|
||||
cp.push(CpInfo::Utf8(class_name.to_string()));
|
||||
let this_name_idx = (cp.len() - 1) as u16;
|
||||
cp.push(CpInfo::Class {
|
||||
name_index: this_name_idx,
|
||||
});
|
||||
let this_class_idx = (cp.len() - 1) as u16;
|
||||
let methods = vec![MethodSpec {
|
||||
name_index: 1,
|
||||
descriptor_index: 2,
|
||||
code_attr_name_index: 3,
|
||||
max_stack: 2,
|
||||
code,
|
||||
}];
|
||||
encode_class(&cp, this_class_idx, &methods)
|
||||
}
|
||||
|
||||
/// A `.class` fixture whose `<clinit>` is exactly `new AudioSlot; dup;
|
||||
/// getstatic LanguageEnum.English; invokespecial AudioSlot.<init>
|
||||
/// (LLanguageEnum;)V` — one real `Construction`, for Jar-level
|
||||
/// `decode_binding` tests. `class_name` only affects the class's own
|
||||
/// `this_class` entry (informational); the Jar-level lookup key is the
|
||||
/// zip entry path passed to `build_zip`, not this name.
|
||||
fn class_with_simple_construction(class_name: &str) -> Vec<u8> {
|
||||
let cp = vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("<clinit>".into()), // 1
|
||||
CpInfo::Utf8("()V".into()), // 2
|
||||
CpInfo::Utf8("Code".into()), // 3
|
||||
CpInfo::Utf8("LanguageEnum".into()), // 4
|
||||
CpInfo::Class { name_index: 4 }, // 5
|
||||
CpInfo::Utf8("English".into()), // 6
|
||||
CpInfo::Utf8("LLanguageEnum;".into()), // 7
|
||||
CpInfo::NameAndType {
|
||||
name_index: 6,
|
||||
descriptor_index: 7,
|
||||
}, // 8
|
||||
CpInfo::Fieldref {
|
||||
class_index: 5,
|
||||
name_and_type_index: 8,
|
||||
}, // 9
|
||||
CpInfo::Utf8("AudioSlot".into()), // 10
|
||||
CpInfo::Class { name_index: 10 }, // 11
|
||||
CpInfo::Utf8("<init>".into()), // 12
|
||||
CpInfo::Utf8("(LLanguageEnum;)V".into()), // 13
|
||||
CpInfo::NameAndType {
|
||||
name_index: 12,
|
||||
descriptor_index: 13,
|
||||
}, // 14
|
||||
CpInfo::Methodref {
|
||||
class_index: 11,
|
||||
name_and_type_index: 14,
|
||||
}, // 15
|
||||
CpInfo::Utf8(class_name.to_string()), // 16
|
||||
CpInfo::Class { name_index: 16 }, // 17
|
||||
];
|
||||
let this_class_idx = 17u16;
|
||||
let code: Vec<u8> = vec![
|
||||
NEW,
|
||||
0,
|
||||
11, // new AudioSlot
|
||||
0x59, // dup
|
||||
GETSTATIC,
|
||||
0,
|
||||
9, // getstatic LanguageEnum.English
|
||||
INVOKESPECIAL,
|
||||
0,
|
||||
15, // invokespecial AudioSlot.<init>(LLanguageEnum;)V
|
||||
];
|
||||
let methods = vec![MethodSpec {
|
||||
name_index: 1,
|
||||
descriptor_index: 2,
|
||||
code_attr_name_index: 3,
|
||||
max_stack: 4,
|
||||
code,
|
||||
}];
|
||||
encode_class(&cp, this_class_idx, &methods)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ldcs_match_prefix_exact() {
|
||||
let ldcs = vec![
|
||||
@@ -1167,6 +1502,188 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase A: identify_master_enums (Jar-level) ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_master_enums_matches_purpose_fingerprint() {
|
||||
// Exact match: 8 ldcs, first 4 = the Purpose prefix, count ==
|
||||
// expected_count exactly (abs_diff == 0). A decoy class with the
|
||||
// same prefix but a wildly different count must be rejected and
|
||||
// must NOT win over the exact match.
|
||||
let good = class_with_ldc_strings(
|
||||
"GoodPurpose",
|
||||
&[
|
||||
"Normal",
|
||||
"Commentary",
|
||||
"PiP",
|
||||
"Trivia",
|
||||
"Descriptive",
|
||||
"Score",
|
||||
"NoForced",
|
||||
"NoForcedDescriptive",
|
||||
],
|
||||
);
|
||||
// Prefix matches but count is 100 — abs_diff(100, 8) = 92, far
|
||||
// outside LDC_COUNT_TOLERANCE (4). Real logic must reject this
|
||||
// class as a Purpose candidate entirely.
|
||||
let mut decoy_values: Vec<&str> = vec!["Normal", "Commentary", "PiP", "Trivia"];
|
||||
let filler: Vec<String> = (0..96).map(|i| format!("Filler{i}")).collect();
|
||||
decoy_values.extend(filler.iter().map(String::as_str));
|
||||
let decoy = class_with_ldc_strings("DecoyPurpose", &decoy_values);
|
||||
|
||||
let zip = build_zip(&[
|
||||
("com/bydeluxe/Good.class", good),
|
||||
("com/bydeluxe/Decoy.class", decoy),
|
||||
]);
|
||||
let mut archive = open_jar(zip);
|
||||
let enums = identify_master_enums(&mut archive);
|
||||
let purpose = enums
|
||||
.iter()
|
||||
.find(|(label, _)| *label == "Purpose")
|
||||
.unwrap_or_else(|| panic!("Purpose fingerprint not matched: {enums:?}"));
|
||||
assert_eq!(purpose.1.class_name, "com/bydeluxe/Good.class");
|
||||
assert_eq!(purpose.1.values.len(), 8);
|
||||
assert_eq!(purpose.1.values[0], "Normal");
|
||||
assert_eq!(purpose.1.values[7], "NoForcedDescriptive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_master_enums_accepts_count_at_the_tolerance_boundary() {
|
||||
// abs_diff(expected_count, count) == LDC_COUNT_TOLERANCE (4) exactly
|
||||
// must still be accepted (`> tolerance` rejects, so `== tolerance`
|
||||
// is the last accepted value). This is the boundary `327:50`
|
||||
// mutants (`>` -> `==`/`<`/`>=`) disagree on.
|
||||
let mut values: Vec<&str> = vec!["Normal", "Commentary", "PiP", "Trivia"];
|
||||
let filler: Vec<String> = (0..8).map(|i| format!("Filler{i}")).collect(); // 4+8=12, diff=4
|
||||
values.extend(filler.iter().map(String::as_str));
|
||||
assert_eq!(values.len(), 12);
|
||||
let class = class_with_ldc_strings("BoundaryPurpose", &values);
|
||||
let zip = build_zip(&[("com/bydeluxe/B.class", class)]);
|
||||
let mut archive = open_jar(zip);
|
||||
let enums = identify_master_enums(&mut archive);
|
||||
assert!(
|
||||
enums.iter().any(|(label, _)| *label == "Purpose"),
|
||||
"a class exactly LDC_COUNT_TOLERANCE away from expected_count must still match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_master_enums_finds_nothing_without_com_bydeluxe_signal() {
|
||||
// No FINGERPRINTS-matching class in the jar at all -> empty result
|
||||
// (kills the `vec![]` mutant only vacuously if paired with the
|
||||
// positive tests above proving non-emptiness on a real match).
|
||||
let unrelated = class_with_ldc_strings("Unrelated", &["Foo", "Bar"]);
|
||||
let zip = build_zip(&[("x/Unrelated.class", unrelated)]);
|
||||
let mut archive = open_jar(zip);
|
||||
assert!(identify_master_enums(&mut archive).is_empty());
|
||||
}
|
||||
|
||||
// ── Phase C: find_binding_classes / count_master_enum_getstatic ────────
|
||||
|
||||
#[test]
|
||||
fn count_master_enum_getstatic_counts_only_master_classes() {
|
||||
// Directly exercises count_master_enum_getstatic on a synthetic
|
||||
// ClassFile (no Jar needed — this function takes &ClassFile).
|
||||
let master: HashSet<&str> = ["LanguageEnum"].into_iter().collect();
|
||||
let code_bytes = class_with_getstatic_refs("X", "LanguageEnum", 5);
|
||||
// Round-trip through ClassFile::parse to get a real &ClassFile.
|
||||
let class =
|
||||
super::super::class_reader::ClassFile::parse(&code_bytes).expect("fixture must parse");
|
||||
assert_eq!(count_master_enum_getstatic(&class, &master), 5);
|
||||
|
||||
// getstatic refs to a class NOT in master_enum_classes must not count.
|
||||
let other_master: HashSet<&str> = ["SomeOtherEnum"].into_iter().collect();
|
||||
assert_eq!(count_master_enum_getstatic(&class, &other_master), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_binding_classes_picks_top_candidates_above_threshold() {
|
||||
// Class A: 100 getstatic refs (the top / binding class). B: 45
|
||||
// (>40% of top, kept). F: 40 (EXACTLY the 40% threshold — pins
|
||||
// both the `(top_count * 2) / 5` arithmetic and the `>=`
|
||||
// comparison: any of the `460`/`461` arithmetic mutants shift
|
||||
// the threshold away from exactly 40, and a `>= -> <` mutant at
|
||||
// 461 would drop this exact-boundary entry). E: 39 (just BELOW
|
||||
// the true 40% threshold — a mutant that shrinks the threshold
|
||||
// below 39 would wrongly keep this). C: 10 (well below, always
|
||||
// dropped). D: 3 — below MIN_GETSTATIC(4), never even a raw
|
||||
// candidate.
|
||||
let master_classes: HashSet<&str> = ["LanguageEnum"].into_iter().collect();
|
||||
let a = class_with_getstatic_refs("A", "LanguageEnum", 100);
|
||||
let b = class_with_getstatic_refs("B", "LanguageEnum", 45);
|
||||
let f = class_with_getstatic_refs("F", "LanguageEnum", 40);
|
||||
let e = class_with_getstatic_refs("E", "LanguageEnum", 39);
|
||||
let c = class_with_getstatic_refs("C", "LanguageEnum", 10);
|
||||
let d = class_with_getstatic_refs("D", "LanguageEnum", 3);
|
||||
let zip = build_zip(&[
|
||||
("com/bydeluxe/A.class", a),
|
||||
("com/bydeluxe/B.class", b),
|
||||
("com/bydeluxe/F.class", f),
|
||||
("com/bydeluxe/E.class", e),
|
||||
("com/bydeluxe/C.class", c),
|
||||
("com/bydeluxe/D.class", d),
|
||||
]);
|
||||
let mut archive = open_jar(zip);
|
||||
let candidates = find_binding_classes(&mut archive, &master_classes);
|
||||
let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"com/bydeluxe/A.class",
|
||||
"com/bydeluxe/B.class",
|
||||
"com/bydeluxe/F.class"
|
||||
],
|
||||
"expected [A(100), B(45), F(40)] retained (>=40% of top, descending \
|
||||
order, E(39)/C(10)/D(3) dropped), got {names:?}"
|
||||
);
|
||||
assert_eq!(candidates[0].1, 100);
|
||||
assert_eq!(candidates[1].1, 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_binding_classes_empty_master_set_yields_no_candidates() {
|
||||
let master_classes: HashSet<&str> = HashSet::new();
|
||||
let a = class_with_getstatic_refs("A", "LanguageEnum", 100);
|
||||
let zip = build_zip(&[("com/bydeluxe/A.class", a)]);
|
||||
let mut archive = open_jar(zip);
|
||||
assert!(find_binding_classes(&mut archive, &master_classes).is_empty());
|
||||
}
|
||||
|
||||
// ── Phase D: decode_binding (Jar-level short-circuit wrapper) ───────────
|
||||
|
||||
#[test]
|
||||
fn decode_binding_finds_named_class_and_stops_at_first_match() {
|
||||
// `decode_binding` matches by the Jar entry path (the same string
|
||||
// `find_binding_classes` returns), not by the class's own
|
||||
// `this_class` name. Two entries: the target path carries a real
|
||||
// `new AudioSlot; dup; getstatic; invokespecial` construction; a
|
||||
// decoy at a different path carries none (and, being pure
|
||||
// getstatic/pop, would also match nothing if walked). If the name
|
||||
// comparison is broken (`!=` mutated to `==`), decode_binding would
|
||||
// either never match the real target (empty result) or would match
|
||||
// and decode the WRONG entry.
|
||||
let target = class_with_simple_construction("Ignored");
|
||||
let decoy = class_with_getstatic_refs("Ignored2", "LanguageEnum", 2);
|
||||
let zip = build_zip(&[
|
||||
("com/bydeluxe/Target.class", target),
|
||||
("com/bydeluxe/Decoy.class", decoy),
|
||||
]);
|
||||
let mut archive = open_jar(zip);
|
||||
let master = lang_enum_master();
|
||||
|
||||
let ctors = decode_binding(&mut archive, "com/bydeluxe/Target.class", &master);
|
||||
assert_eq!(
|
||||
ctors.len(),
|
||||
1,
|
||||
"expected the Target entry's one construction"
|
||||
);
|
||||
assert_eq!(ctors[0].binding_type, "AudioSlot");
|
||||
|
||||
// A name with no matching entry must yield nothing (try_each_class
|
||||
// never finds a Some).
|
||||
assert!(decode_binding(&mut archive, "com/bydeluxe/NoSuchClass.class", &master).is_empty());
|
||||
}
|
||||
|
||||
// ── Phase D bytecode walker tests ───────────────────────────────────────
|
||||
|
||||
use super::super::class_reader::{ConstantPool, CpInfo};
|
||||
@@ -1304,6 +1821,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clinit_ldc_string_bytes_boundary_matches_256kib_not_1280() {
|
||||
// `MAX_CLINIT_LDC_BYTES = 256 * 1024` (262144). A `* -> +` mutant at
|
||||
// that computation collapses the cap to `256 + 1024` (1280) — 205x
|
||||
// smaller. 1000-byte strings make the two cap values discriminate
|
||||
// sharply: correct code retains 262 of them (262000 bytes, the
|
||||
// 263rd would push to 263000 > 262144); the mutant retains only 1
|
||||
// (the 2nd would push to 2000 > 1280).
|
||||
const N: usize = 400;
|
||||
let one = "x".repeat(1000);
|
||||
let mut code = Vec::with_capacity(N * 2);
|
||||
for _ in 0..N {
|
||||
code.push(LDC);
|
||||
code.push(4);
|
||||
}
|
||||
let class = class_with_clinit(ldc_pool(&one), 2, &code);
|
||||
let ldcs = clinit_ldc_strings(&class).expect("<clinit> present");
|
||||
assert_eq!(
|
||||
ldcs.len(),
|
||||
262,
|
||||
"expected 262 retained 1000-byte strings under a 256 KiB cap, got {} \
|
||||
— either the cap value or the truncation arithmetic changed",
|
||||
ldcs.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clinit_ldc_strings_admits_largest_real_fingerprint() {
|
||||
// The biggest framework-stable enum is Language at 70 values; the cap
|
||||
@@ -1398,6 +1941,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `insert` rejects when `bytes.saturating_add(cost) > MAX_CANDIDATE_TOTAL_BYTES`
|
||||
/// — i.e. landing EXACTLY on the cap is still accepted; only strictly
|
||||
/// exceeding it is rejected. A `>` -> `>=` mutant would reject the
|
||||
/// exact-cap entry too. Two entries are sized so the second brings
|
||||
/// `bytes` to precisely `MAX_CANDIDATE_TOTAL_BYTES`, not one byte over.
|
||||
#[test]
|
||||
fn candidate_pool_insert_accepts_landing_exactly_on_the_cap() {
|
||||
let mut pool = CandidatePool::default();
|
||||
// cost = name.len() + payload.len() = 1 + (CAP - 2) = CAP - 1.
|
||||
let first_payload = "a".repeat(MAX_CANDIDATE_TOTAL_BYTES - 2);
|
||||
assert!(pool.insert("a", vec![first_payload]));
|
||||
assert_eq!(pool.bytes, MAX_CANDIDATE_TOTAL_BYTES - 1);
|
||||
|
||||
// cost = 1 (name "b") + 0 (empty string) = 1. bytes becomes exactly
|
||||
// MAX_CANDIDATE_TOTAL_BYTES — must be ACCEPTED, not rejected.
|
||||
let accepted = pool.insert("b", vec![String::new()]);
|
||||
assert!(
|
||||
accepted,
|
||||
"an entry landing exactly on MAX_CANDIDATE_TOTAL_BYTES must be accepted, \
|
||||
only entries that exceed it should be rejected"
|
||||
);
|
||||
assert_eq!(pool.bytes, MAX_CANDIDATE_TOTAL_BYTES);
|
||||
|
||||
// One more byte of cost now genuinely exceeds the cap and must be rejected.
|
||||
assert!(!pool.insert("c", vec!["x".to_string()]));
|
||||
}
|
||||
|
||||
// ── Construction accumulation bounds ────────────────────────────────────
|
||||
|
||||
/// One `new X / dup / ... / invokespecial X.<init>` per 11 code bytes, so
|
||||
@@ -1574,6 +2144,80 @@ mod tests {
|
||||
MasterEnumTable::from(&[("Language", m)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_binding_class_finds_the_clinit_method_and_emits_its_construction() {
|
||||
// decode_binding_class wraps BindingDecoder over every method literally
|
||||
// named "<clinit>" on the class. Exercises the method-selection
|
||||
// (`member_name(m) != Some("<clinit>")`) and per-method-union
|
||||
// truncation (`room == 0`) logic that decode_binding_class adds on
|
||||
// top of the already-tested BindingDecoder::step/run.
|
||||
//
|
||||
// Pool layout (must hold "<clinit>"/"()V"/"Code" at 1/2/3 per
|
||||
// `class_with_clinit`'s contract, while ALSO matching the fixed cp
|
||||
// indices — 6/8/12 — the reused `new AudioSlot; dup; getstatic;
|
||||
// invokespecial` bytecode below references):
|
||||
// 1 Utf8 "<clinit>" 2 Utf8 "()V" 3 Utf8 "Code"
|
||||
// 4 Utf8 "LanguageEnum" 5 Class->4
|
||||
// 6 Fieldref{class:5,nat:9} 7 Utf8 "English"
|
||||
// 8 Class->10 (AudioSlot) 9 NameAndType{name:7,desc:11}
|
||||
// 10 Utf8 "AudioSlot" 11 Utf8 "LLanguageEnum;"
|
||||
// 12 Methodref{class:8,nat:13}
|
||||
// 13 NameAndType{name:14,desc:15}
|
||||
// 14 Utf8 "<init>" 15 Utf8 "(LLanguageEnum;)V"
|
||||
let pool = ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("<clinit>".into()),
|
||||
CpInfo::Utf8("()V".into()),
|
||||
CpInfo::Utf8("Code".into()),
|
||||
CpInfo::Utf8("LanguageEnum".into()),
|
||||
CpInfo::Class { name_index: 4 },
|
||||
CpInfo::Fieldref {
|
||||
class_index: 5,
|
||||
name_and_type_index: 9,
|
||||
},
|
||||
CpInfo::Utf8("English".into()),
|
||||
CpInfo::Class { name_index: 10 },
|
||||
CpInfo::NameAndType {
|
||||
name_index: 7,
|
||||
descriptor_index: 11,
|
||||
},
|
||||
CpInfo::Utf8("AudioSlot".into()),
|
||||
CpInfo::Utf8("LLanguageEnum;".into()),
|
||||
CpInfo::Methodref {
|
||||
class_index: 8,
|
||||
name_and_type_index: 13,
|
||||
},
|
||||
CpInfo::NameAndType {
|
||||
name_index: 14,
|
||||
descriptor_index: 15,
|
||||
},
|
||||
CpInfo::Utf8("<init>".into()),
|
||||
CpInfo::Utf8("(LLanguageEnum;)V".into()),
|
||||
]);
|
||||
let code: Vec<u8> = vec![
|
||||
NEW,
|
||||
0,
|
||||
8, // new AudioSlot
|
||||
0x59, // dup
|
||||
GETSTATIC,
|
||||
0,
|
||||
6, // getstatic LanguageEnum.English
|
||||
INVOKESPECIAL,
|
||||
0,
|
||||
12, // invokespecial AudioSlot.<init>(LLanguageEnum;)V
|
||||
];
|
||||
let class = class_with_clinit(pool, 4, &code);
|
||||
let master = lang_enum_master();
|
||||
let constructions = decode_binding_class(&class, &master);
|
||||
assert_eq!(
|
||||
constructions.len(),
|
||||
1,
|
||||
"expected exactly 1 Construction from the single <clinit>, got {}",
|
||||
constructions.len()
|
||||
);
|
||||
assert_eq!(constructions[0].binding_type, "AudioSlot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_recognizes_simple_construction() {
|
||||
// Synthetic <clinit>:
|
||||
@@ -1655,6 +2299,217 @@ mod tests {
|
||||
assert_eq!(decoder.constructions.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_dup_duplicates_top_of_stack() {
|
||||
// JVMS §3.11.7 `dup` (0x59): duplicate the top stack value. Checked
|
||||
// directly on `decoder.stack` (not via emitted Constructions, which
|
||||
// a single `new X; dup; invokespecial` sequence can satisfy either
|
||||
// way — the leftover copy `dup` is responsible for only matters
|
||||
// once something ELSE consumes it afterward). `new AudioSlot; dup`
|
||||
// with no invokespecial must leave exactly two NewObj("AudioSlot")
|
||||
// entries.
|
||||
let pool = build_simple_pool();
|
||||
let master = lang_enum_master();
|
||||
let code: Vec<u8> = vec![NEW, 0, 8, 0x59 /* dup */];
|
||||
let attr = super::super::class_reader::CodeAttribute {
|
||||
max_stack: 4,
|
||||
max_locals: 0,
|
||||
code: &code,
|
||||
};
|
||||
let mut decoder = BindingDecoder::new(&pool, &master);
|
||||
decoder.run(&attr);
|
||||
assert_eq!(
|
||||
decoder.stack.len(),
|
||||
2,
|
||||
"dup must duplicate, not skip, the top value"
|
||||
);
|
||||
for v in &decoder.stack {
|
||||
match v {
|
||||
StackVal::NewObj(name) => assert_eq!(name, "AudioSlot"),
|
||||
other => panic!("expected NewObj(\"AudioSlot\") x2, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pool with a single Methodref (cp index 6) to `AnyClass.m<descriptor>`,
|
||||
/// for the `invokevirtual`/`invokestatic`/`invokeinterface` arg-popping
|
||||
/// tests below.
|
||||
fn call_ref_pool(descriptor: &str) -> ConstantPool {
|
||||
ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("AnyClass".into()), // 1
|
||||
CpInfo::Class { name_index: 1 }, // 2
|
||||
CpInfo::Utf8("m".into()), // 3
|
||||
CpInfo::Utf8(descriptor.to_string()), // 4
|
||||
CpInfo::NameAndType {
|
||||
name_index: 3,
|
||||
descriptor_index: 4,
|
||||
}, // 5
|
||||
CpInfo::Methodref {
|
||||
class_index: 2,
|
||||
name_and_type_index: 5,
|
||||
}, // 6
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_invokevirtual_pops_receiver_plus_args() {
|
||||
// JVMS §6.5 `invokevirtual`/`invokeinterface` pop the receiver
|
||||
// PLUS the descriptor's args (`extra = 1` for opcodes 0xB6/0xB9);
|
||||
// `invokestatic` (0xB8) pops ONLY the args (no receiver). Each
|
||||
// case below pushes exactly `to_pop` placeholder ints and checks
|
||||
// the stack is fully drained — a wrong `extra`/`arg_count+extra`
|
||||
// computation leaves a wrong number of leftovers.
|
||||
let master = lang_enum_master();
|
||||
let run_stack_len = |opcode: u8, descriptor: &str, n_pushes: usize| -> usize {
|
||||
let pool = call_ref_pool(descriptor);
|
||||
let mut code = Vec::new();
|
||||
for i in 0..n_pushes {
|
||||
code.push(ICONST_0 + i as u8); // distinct placeholder ints
|
||||
}
|
||||
code.push(opcode);
|
||||
code.push(0);
|
||||
code.push(6);
|
||||
if opcode == 0xB9 {
|
||||
// invokeinterface (JVMS §6.5): 2 extra operand bytes —
|
||||
// `count` (here: arg slot count + 1 for the receiver, per
|
||||
// spec) and a reserved zero byte.
|
||||
code.push((n_pushes) as u8);
|
||||
code.push(0);
|
||||
}
|
||||
let attr = super::super::class_reader::CodeAttribute {
|
||||
max_stack: 8,
|
||||
max_locals: 0,
|
||||
code: &code,
|
||||
};
|
||||
let mut decoder = BindingDecoder::new(&pool, &master);
|
||||
decoder.run(&attr);
|
||||
decoder.stack.len()
|
||||
};
|
||||
|
||||
// invokevirtual, 1-arg descriptor: pops receiver + 1 arg = 2.
|
||||
assert_eq!(
|
||||
run_stack_len(0xB6, "(I)V", 2),
|
||||
0,
|
||||
"invokevirtual must pop receiver + args"
|
||||
);
|
||||
// invokeinterface, 1-arg descriptor: same as invokevirtual.
|
||||
assert_eq!(
|
||||
run_stack_len(0xB9, "(I)V", 2),
|
||||
0,
|
||||
"invokeinterface must pop receiver + args"
|
||||
);
|
||||
// invokestatic, 2-arg descriptor: pops ONLY the 2 args, no receiver.
|
||||
assert_eq!(
|
||||
run_stack_len(0xB8, "(II)V", 2),
|
||||
0,
|
||||
"invokestatic must pop exactly the arg count, no receiver"
|
||||
);
|
||||
// invokestatic with a leftover value UNDER the args: only the args
|
||||
// are popped, the leftover survives. Distinguishes a `>` mutant at
|
||||
// the `len < to_pop` guard (which would incorrectly `clear()` the
|
||||
// whole stack here instead of leaving the leftover).
|
||||
assert_eq!(
|
||||
run_stack_len(0xB8, "(I)V", 2), // 1 leftover + 1 real arg pushed
|
||||
1,
|
||||
"only the descriptor's args must be popped, not the whole stack"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_invoke_family_defensively_clears_on_stack_underflow() {
|
||||
// If the symbolic stack has FEWER entries than the call needs to
|
||||
// pop (malformed/adversarial bytecode, or earlier drift), the
|
||||
// decoder must defensively clear rather than underflow-subtract
|
||||
// (`len - to_pop` with `len < to_pop` would panic on the `usize`
|
||||
// subtraction).
|
||||
let pool = call_ref_pool("(II)V"); // needs to_pop = 2
|
||||
let code: Vec<u8> = vec![ICONST_0, 0xB8, 0, 6]; // only 1 value on stack
|
||||
let master = lang_enum_master();
|
||||
let attr = super::super::class_reader::CodeAttribute {
|
||||
max_stack: 8,
|
||||
max_locals: 0,
|
||||
code: &code,
|
||||
};
|
||||
let mut decoder = BindingDecoder::new(&pool, &master);
|
||||
decoder.run(&attr);
|
||||
assert_eq!(
|
||||
decoder.stack.len(),
|
||||
0,
|
||||
"stack-underflowing invoke must clear defensively, not underflow-subtract"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pool for a single-int-arg constructor `AudioSlot.<init>(I)V`, used by
|
||||
/// `binding_decoder_int_push_opcodes_produce_the_right_value` to isolate
|
||||
/// each int-push opcode's produced VALUE (not just "a construction
|
||||
/// happened") — JVMS §3.11.3 (`iconst_<i>`, `bipush`, `sipush`, `ldc` of
|
||||
/// a `CONSTANT_Integer`) each push a specific known int.
|
||||
fn int_ctor_pool() -> ConstantPool {
|
||||
ConstantPool::from_entries(vec![
|
||||
CpInfo::Empty,
|
||||
CpInfo::Utf8("AudioSlot".into()), // 1
|
||||
CpInfo::Class { name_index: 1 }, // 2
|
||||
CpInfo::Utf8("<init>".into()), // 3
|
||||
CpInfo::Utf8("(I)V".into()), // 4
|
||||
CpInfo::NameAndType {
|
||||
name_index: 3,
|
||||
descriptor_index: 4,
|
||||
}, // 5
|
||||
CpInfo::Methodref {
|
||||
class_index: 2,
|
||||
name_and_type_index: 5,
|
||||
}, // 6
|
||||
CpInfo::Integer(12345), // 7 — for the `ldc`/Integer case
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_int_push_opcodes_produce_the_right_value() {
|
||||
// JVMS §3.11.3: iconst_<i> pushes exactly i (i in -1..=5); bipush
|
||||
// sign-extends its i8 operand; sipush sign-extends its i16 operand;
|
||||
// ldc of a CONSTANT_Integer pushes that constant. Each is checked
|
||||
// as the sole arg of `new AudioSlot; dup; <push>; invokespecial
|
||||
// AudioSlot.<init>(I)V` so a wrong (or absent, if the opcode's match
|
||||
// arm were deleted) push shows up as a wrong (or missing/Unknown)
|
||||
// arg value, not just "some construction happened".
|
||||
let cases: Vec<(&str, Vec<u8>, i32)> = vec![
|
||||
("iconst_m1", vec![ICONST_M1], -1),
|
||||
("iconst_0", vec![ICONST_0], 0),
|
||||
("iconst_1", vec![ICONST_1], 1),
|
||||
("iconst_2", vec![ICONST_2], 2),
|
||||
("iconst_3", vec![ICONST_3], 3),
|
||||
("iconst_4", vec![ICONST_4], 4),
|
||||
("iconst_5", vec![ICONST_5], 5),
|
||||
("bipush -100", vec![BIPUSH, 0x9C], -100), // 0x9C as i8 = -100
|
||||
("sipush 4660", vec![SIPUSH, 0x12, 0x34], 4660), // 0x1234
|
||||
("ldc Integer(12345)", vec![LDC, 7], 12345),
|
||||
];
|
||||
let pool = int_ctor_pool();
|
||||
let master = lang_enum_master();
|
||||
for (label, push, expected) in cases {
|
||||
let mut code = vec![NEW, 0, 2, 0x59 /* dup */];
|
||||
code.extend_from_slice(&push);
|
||||
code.extend_from_slice(&[INVOKESPECIAL, 0, 6]);
|
||||
let attr = super::super::class_reader::CodeAttribute {
|
||||
max_stack: 4,
|
||||
max_locals: 0,
|
||||
code: &code,
|
||||
};
|
||||
let mut decoder = BindingDecoder::new(&pool, &master);
|
||||
decoder.run(&attr);
|
||||
assert_eq!(
|
||||
decoder.constructions.len(),
|
||||
1,
|
||||
"{label}: expected exactly 1 construction"
|
||||
);
|
||||
match &decoder.constructions[0].args[0] {
|
||||
StackVal::Int(n) => assert_eq!(*n, expected, "{label}: wrong int value"),
|
||||
other => panic!("{label}: expected StackVal::Int({expected}), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_decoder_skips_unmatched_invokespecial() {
|
||||
// invokespecial without a preceding `new X; dup` — should
|
||||
|
||||
@@ -216,6 +216,27 @@ mod tests {
|
||||
ZipArchive::new(Cursor::new(bytes)).expect("valid zip")
|
||||
}
|
||||
|
||||
/// The doc comment states the cap is 64 MiB. Pin the exact numeric
|
||||
/// value (not derived from the same `64 * 1024 * 1024` expression
|
||||
/// under test — a hardcoded literal) so a mutation of the arithmetic
|
||||
/// (e.g. `*` -> `+`) is caught even though no test builds an actual
|
||||
/// 64 MiB buffer.
|
||||
#[test]
|
||||
fn max_class_bytes_is_64_mebibytes() {
|
||||
assert_eq!(MAX_CLASS_BYTES, 67_108_864);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_path_prefix_matches_only_declared_prefix() {
|
||||
let jar = open(build_stored_zip(
|
||||
"com/dbp/Loader.class",
|
||||
MINIMAL_CLASS,
|
||||
MINIMAL_CLASS.len() as u32,
|
||||
));
|
||||
assert!(has_path_prefix(&jar, "com/dbp/"));
|
||||
assert!(!has_path_prefix(&jar, "com/bydeluxe/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_each_class_reads_minimal_class() {
|
||||
let mut jar = open(build_stored_zip(
|
||||
|
||||
@@ -1885,6 +1885,32 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec: fill_defaults must not clobber a video label that's already
|
||||
/// set (mirrors the audio preserve-existing-label contract above).
|
||||
/// Mutation: replace the `v.label.is_empty()` guard with `true` so the
|
||||
/// Video arm always fires, wiping out a pre-set label.
|
||||
#[test]
|
||||
fn fill_defaults_preserves_existing_video_label() {
|
||||
let mut titles = vec![title_with(vec![Stream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R2160p,
|
||||
frame_rate: FrameRate::F23_976,
|
||||
hdr: HdrFormat::Hdr10,
|
||||
color_space: ColorSpace::Bt2020,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: "Pre-set 4K HDR".into(),
|
||||
measured_cicp: None,
|
||||
})])];
|
||||
fill_defaults(&mut titles);
|
||||
if let Stream::Video(v) = &titles[0].streams[0] {
|
||||
assert_eq!(v.label, "Pre-set 4K HDR");
|
||||
} else {
|
||||
panic!("expected video stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_defaults_generates_video_label_with_hdr() {
|
||||
let mut titles = vec![title_with(vec![video()])];
|
||||
@@ -2086,4 +2112,247 @@ mod apply_tests {
|
||||
assert!(!codec_hint_adds_detail("Dolby Digital Plus 5.1"));
|
||||
assert!(!codec_hint_adds_detail(""));
|
||||
}
|
||||
|
||||
// ── generate_video_label hardening ─────────────────────────────────────
|
||||
|
||||
/// Spec: a secondary (dependent-view) video stream with Dolby Vision
|
||||
/// enhancement layer gets the brand string "Dolby Vision EL"; every
|
||||
/// other HDR format on a secondary stream gets no label at all (that
|
||||
/// wording is a CLI concern).
|
||||
/// Mutation: delete the `HdrFormat::DolbyVision` arm so it falls
|
||||
/// through to the `_ => String::new()` catch-all, losing the brand.
|
||||
#[test]
|
||||
fn generate_video_label_secondary_dolby_vision_el() {
|
||||
assert_eq!(
|
||||
generate_video_label(
|
||||
&Codec::Hevc,
|
||||
(3840, 2160),
|
||||
false,
|
||||
&HdrFormat::DolbyVision,
|
||||
true
|
||||
),
|
||||
"Dolby Vision EL"
|
||||
);
|
||||
// Every other HDR format on a secondary stream: empty, not text.
|
||||
assert_eq!(
|
||||
generate_video_label(&Codec::Hevc, (3840, 2160), false, &HdrFormat::Hdr10, true),
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: 480 lines is the SD floor — a stream with height exactly 480
|
||||
/// must get the "480p"/"480i" token (BD spec height boundary), not fall
|
||||
/// through to the empty-resolution case.
|
||||
/// Mutation: `h >= 480` -> `h < 480` inverts the boundary so a legitimate
|
||||
/// 480-line stream (h == 480) produces no resolution token at all.
|
||||
#[test]
|
||||
fn generate_video_label_480_boundary() {
|
||||
let label = generate_video_label(&Codec::Mpeg2, (0, 480), false, &HdrFormat::Sdr, false);
|
||||
assert!(
|
||||
label.contains("480p"),
|
||||
"h == 480 must resolve to 480p, got {label:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: SDR is the unmarked default — it must never appear as a token
|
||||
/// in the generated label (only non-SDR formats get an explicit tag).
|
||||
/// Mutation: delete the `HdrFormat::Sdr` arm so it falls through to
|
||||
/// `_ => parts.push(hdr.name())`, appending a spurious "SDR" token.
|
||||
#[test]
|
||||
fn generate_video_label_sdr_produces_no_hdr_token() {
|
||||
assert_eq!(
|
||||
generate_video_label(&Codec::Hevc, (1920, 1080), false, &HdrFormat::Sdr, false),
|
||||
"HEVC 1080p"
|
||||
);
|
||||
}
|
||||
|
||||
// ── generate_audio_label_atmos ───────────────────────────────────────
|
||||
|
||||
/// Spec: the Atmos-aware variant folds "Atmos" into the codec brand
|
||||
/// name for TrueHD/DD+ carriers, distinct from the plain wrapper.
|
||||
/// Mutation: stub the whole function to `String::new()` / a constant
|
||||
/// literal — either way it stops reflecting the codec/channel inputs.
|
||||
#[test]
|
||||
fn generate_audio_label_atmos_folds_brand() {
|
||||
assert_eq!(
|
||||
generate_audio_label_atmos(&Codec::TrueHd, &AudioChannels::Surround71, false),
|
||||
"Dolby TrueHD Atmos 7.1"
|
||||
);
|
||||
assert_eq!(
|
||||
generate_audio_label_atmos(&Codec::Ac3Plus, &AudioChannels::Surround51, false),
|
||||
"Dolby Digital Plus Atmos 5.1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: every disc-audio codec in the enum has a full marketing name,
|
||||
/// including the lossy PC-container codecs (AAC/MP2/MP3/FLAC/Opus) that
|
||||
/// `generate_audio_label_all_codecs` above doesn't cover.
|
||||
/// Mutation: delete any one of these match arms — the codec falls
|
||||
/// through to `_ => return String::new()`, silently losing its label.
|
||||
#[test]
|
||||
fn generate_audio_label_covers_pc_container_codecs() {
|
||||
assert_eq!(
|
||||
generate_audio_label(&Codec::Aac, &AudioChannels::Stereo, false),
|
||||
"AAC 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
generate_audio_label(&Codec::Mp2, &AudioChannels::Stereo, false),
|
||||
"MPEG Audio 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
generate_audio_label(&Codec::Mp3, &AudioChannels::Stereo, false),
|
||||
"MP3 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
generate_audio_label(&Codec::Flac, &AudioChannels::Stereo, false),
|
||||
"FLAC 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
generate_audio_label(&Codec::Opus, &AudioChannels::Stereo, false),
|
||||
"Opus 2.0"
|
||||
);
|
||||
}
|
||||
|
||||
// ── codec_hint_consistent: chained-OR boundary hardening ────────────────
|
||||
//
|
||||
// The family-detection booleans are built from chains of `h.contains(..)
|
||||
// || h.contains(..) || ...` synonym checks. Each test below isolates ONE
|
||||
// synonym clause (a hint string that matches that clause and NO other
|
||||
// clause in the same chain) so a `||` -> `&&` flip at that specific
|
||||
// position changes the family verdict — and, downstream, whether the
|
||||
// codec match arm returns the spec-correct answer.
|
||||
|
||||
/// Isolates the `"true hd"` (space form) synonym in `says_truehd`,
|
||||
/// which mutant testing hit at 396:44's `||`. If that `||` is
|
||||
/// weakened to `&&`, "True HD" alone (no "truehd" substring) no longer
|
||||
/// sets `says_truehd`, `names_family` goes false entirely (no other
|
||||
/// family clause matches), and the function takes the "no family
|
||||
/// named" early-return path — turning a should-be-`false` verdict for
|
||||
/// a mismatched codec into `true`.
|
||||
#[test]
|
||||
fn codec_hint_consistent_truehd_space_synonym() {
|
||||
assert!(codec_hint_consistent("True HD 7.1", &Codec::TrueHd));
|
||||
assert!(!codec_hint_consistent("True HD 7.1", &Codec::Ac3));
|
||||
}
|
||||
|
||||
/// Isolates the `"ac3+"` (no-hyphen) synonym in `says_ddp` (398:9's
|
||||
/// `||`). A hint matching only this clause must still classify as
|
||||
/// DD+, not fall through to the plain-AC3 `says_ac3` check.
|
||||
#[test]
|
||||
fn codec_hint_consistent_ddp_ac3_plus_no_hyphen_synonym() {
|
||||
assert!(codec_hint_consistent("AC3+ 5.1", &Codec::Ac3Plus));
|
||||
assert!(!codec_hint_consistent("AC3+ 5.1", &Codec::Ac3));
|
||||
}
|
||||
|
||||
/// Isolates the `"eac3"` synonym in `says_ddp` (401:9's `||`), the
|
||||
/// last clause before the chain moves to "digital plus"/"dd+".
|
||||
#[test]
|
||||
fn codec_hint_consistent_ddp_eac3_synonym() {
|
||||
assert!(codec_hint_consistent("EAC3 5.1", &Codec::Ac3Plus));
|
||||
assert!(!codec_hint_consistent("EAC3 5.1", &Codec::Ac3));
|
||||
}
|
||||
|
||||
/// Isolates the `"pcm"` (no "lpcm") synonym in `says_lpcm` (409:40's
|
||||
/// `||`). A bare "PCM" hint on a non-LPCM stream must still be judged
|
||||
/// inconsistent — if the `||` were `&&`, "PCM" alone would fail to set
|
||||
/// `says_lpcm`, `names_family` would go false, and the function would
|
||||
/// take the "no family named" path, wrongly returning `true` for ANY
|
||||
/// codec.
|
||||
#[test]
|
||||
fn codec_hint_consistent_lpcm_bare_pcm_synonym() {
|
||||
assert!(codec_hint_consistent("PCM", &Codec::Lpcm));
|
||||
assert!(!codec_hint_consistent("PCM", &Codec::Ac3));
|
||||
}
|
||||
|
||||
/// Isolates the `says_dts_ma || says_dts_hr` disjunction inside the
|
||||
/// `names_family` chain (418:60). A hint that sets `says_dts_ma` alone
|
||||
/// (e.g. "Master Audio", without "hd ma") must still make
|
||||
/// `names_family` true; weakening that `||` to `&&` requires both
|
||||
/// clauses at once, so `names_family` goes false and the function
|
||||
/// wrongly reports "consistent" for a codec the hint never named.
|
||||
#[test]
|
||||
fn codec_hint_consistent_names_family_dts_ma_alone() {
|
||||
assert!(!codec_hint_consistent("Master Audio", &Codec::Ac3));
|
||||
assert!(codec_hint_consistent("Master Audio", &Codec::DtsHdMa));
|
||||
}
|
||||
|
||||
/// Isolates the `Codec::TrueHd => says_truehd || says_atmos` arm
|
||||
/// (433:38). An Atmos-tagged hint that names a DIFFERENT lossless
|
||||
/// carrier by name (DD+) must still be judged consistent with a
|
||||
/// TrueHd stream purely on the Atmos marker — `||` -> `&&` would
|
||||
/// require the hint to ALSO say "truehd", which an Atmos-only marker
|
||||
/// doesn't.
|
||||
#[test]
|
||||
fn codec_hint_consistent_truehd_arm_atmos_alone() {
|
||||
assert!(codec_hint_consistent(
|
||||
"Dolby Digital Plus Atmos",
|
||||
&Codec::TrueHd
|
||||
));
|
||||
}
|
||||
|
||||
/// Spec: `Codec::Dts` is consistent ONLY when the hint's DTS-family
|
||||
/// bookkeeping (`says_dts`) is true, not just because `names_family` is
|
||||
/// true via some other carrier.
|
||||
/// Mutation: delete the `Codec::Dts => says_dts` arm (438:9) — it falls
|
||||
/// to `_ => true`, so ANY named family is (wrongly) "consistent" with
|
||||
/// a Dts stream.
|
||||
#[test]
|
||||
fn codec_hint_consistent_dts_arm_not_bypassed() {
|
||||
assert!(!codec_hint_consistent("Dolby Digital", &Codec::Dts));
|
||||
}
|
||||
|
||||
/// Spec: `Codec::Lpcm` is consistent ONLY when `says_lpcm` is true.
|
||||
/// Mutation: delete the `Codec::Lpcm => says_lpcm` arm (439:9) — same
|
||||
/// bypass-to-`_ => true` failure mode as the Dts arm above.
|
||||
#[test]
|
||||
fn codec_hint_consistent_lpcm_arm_not_bypassed() {
|
||||
assert!(!codec_hint_consistent("Dolby Digital", &Codec::Lpcm));
|
||||
}
|
||||
}
|
||||
|
||||
// ── fill_gaps_from_mpls: no-op-when-nothing-added hardening ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod fill_gaps_sort_tests {
|
||||
use super::*;
|
||||
|
||||
fn label(t: StreamLabelType, n: u16, lang: &str, codec: &str) -> StreamLabel {
|
||||
StreamLabel {
|
||||
stream_number: n,
|
||||
stream_type: t,
|
||||
language: lang.into(),
|
||||
name: String::new(),
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: codec.into(),
|
||||
variant: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spec: the sort-by-(type, number) pass only runs when the merge
|
||||
/// actually added something (`added > 0`); when MPLS contributed
|
||||
/// nothing new, `framework`'s existing order (however the caller built
|
||||
/// it) must be left untouched.
|
||||
/// Mutation: `added > 0` -> `added >= 0` is always true, so the sort
|
||||
/// runs unconditionally, silently reordering a framework list that
|
||||
/// wasn't already in (type, number) order even on a no-op merge.
|
||||
#[test]
|
||||
fn fill_gaps_leaves_order_untouched_when_nothing_added() {
|
||||
// Deliberately out of (type, number) order: number 2 before 1.
|
||||
let mut framework = vec![
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
];
|
||||
// MPLS covers exactly the same (type, number) slots -> added == 0.
|
||||
let mpls = vec![
|
||||
label(StreamLabelType::Audio, 1, "eng", "TrueHD"),
|
||||
label(StreamLabelType::Audio, 2, "fra", "AC-3"),
|
||||
];
|
||||
fill_gaps_from_mpls(&mut framework, &mpls);
|
||||
assert_eq!(
|
||||
framework[0].stream_number, 2,
|
||||
"no gap-fill happened, so the original (out-of-order) sequence must survive"
|
||||
);
|
||||
assert_eq!(framework[1].stream_number, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,37 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut playlists: Vec<crate::mpls::Playlist> = Vec::new();
|
||||
for name in &mpls_names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
playlists.push(playlist);
|
||||
}
|
||||
|
||||
let labels = build_labels(&playlists);
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// MPLS gives language + codec but never editorial info (no
|
||||
// commentary/SDH/director's cut). Low confidence means framework
|
||||
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
|
||||
// win when they match. MPLS only gets chosen as the parser when
|
||||
// nothing else fired — exactly the universal-fallback role we want.
|
||||
Some(ParseResult::low(labels))
|
||||
}
|
||||
|
||||
/// Convert every stream entry across `playlists` into deduped
|
||||
/// [`StreamLabel`]s. Factored out of [`parse`] so unit tests can drive
|
||||
/// the actual conversion logic (stream-type mapping, dedup key, dense
|
||||
/// global counters) directly from already-parsed [`crate::mpls::Playlist`]
|
||||
/// values, without needing a synthetic on-disc UDF image.
|
||||
fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
||||
// canonical "same physical stream" key; type+lang+codec round
|
||||
@@ -77,15 +108,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for name in &mpls_names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for playlist in playlists {
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
||||
@@ -130,17 +153,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// MPLS gives language + codec but never editorial info (no
|
||||
// commentary/SDH/director's cut). Low confidence means framework
|
||||
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
|
||||
// win when they match. MPLS only gets chosen as the parser when
|
||||
// nothing else fired — exactly the universal-fallback role we want.
|
||||
Some(ParseResult::low(labels))
|
||||
labels
|
||||
}
|
||||
|
||||
fn has_mpls_extension(name: &str) -> bool {
|
||||
@@ -336,60 +349,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the same conversion logic that `parse()` runs on real
|
||||
/// disc data, but starting from already-parsed Playlists so we
|
||||
/// don't have to synthesize valid MPLS bytes.
|
||||
/// Drive the actual production conversion logic (`build_labels`, the
|
||||
/// function `parse()` calls) starting from already-parsed Playlists,
|
||||
/// so tests don't have to synthesize valid on-disc MPLS/UDF bytes.
|
||||
/// This calls the *real* code under test rather than a hand-written
|
||||
/// re-implementation, so mutations inside `build_labels` (stream-type
|
||||
/// mapping, dedup key, counters) are actually caught here.
|
||||
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
let mut seen: Vec<(StreamLabelType, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global counters hoisted OUT of the playlist loop to match
|
||||
// production `parse()` (lines 77-78): stream_numbers are dense
|
||||
// per type across the whole disc, not reset per playlist.
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for playlist in playlists {
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio,
|
||||
3 => StreamLabelType::Subtitle,
|
||||
_ => continue,
|
||||
};
|
||||
// Dedup BEFORE consuming a counter value, matching prod
|
||||
// parse() ordering so a deduped duplicate does not burn a
|
||||
// stream number.
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (label_type, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
labels
|
||||
build_labels(playlists)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -470,4 +470,23 @@ mod tests {
|
||||
assert!(find_feature_playlist("").is_none());
|
||||
assert!(find_feature_playlist("<root />").is_none());
|
||||
}
|
||||
|
||||
/// Spec: on a tie in audio-slot count, the FIRST playlist encountered
|
||||
/// wins (consistent with `select_result`'s first-wins tiebreak
|
||||
/// elsewhere in the registry) — later playlists only displace the
|
||||
/// current best on a STRICTLY greater count.
|
||||
/// Mutation: `count > best_aud_count` -> `count >= best_aud_count`
|
||||
/// would let a later tied playlist silently displace the first.
|
||||
#[test]
|
||||
fn find_feature_first_wins_on_audio_count_tie() {
|
||||
let xml = r#"
|
||||
<playlist name="A" aud="eng,fra" />
|
||||
<playlist name="B" aud="deu,spa" />
|
||||
"#;
|
||||
let feature = find_feature_playlist(xml).expect("a feature is found");
|
||||
assert!(
|
||||
feature.contains(r#"name="A""#),
|
||||
"first playlist must win a tie, got: {feature}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +638,87 @@ mod tests {
|
||||
assert!(audio.is_empty() || audio.iter().all(|l| l.stream_number <= 512));
|
||||
}
|
||||
|
||||
/// Spec: the FPL section also ends on an `SF_` marker (not just
|
||||
/// `SEG_`/`FPL_`). Only `assign_labels_fpl_section_ends_on_seg_boundary`
|
||||
/// existed before, which cannot distinguish a mutated `||` chain from
|
||||
/// the correct one (any single true operand already ends the section).
|
||||
/// This test isolates the `SF_` alternative specifically.
|
||||
/// Mutation: `||` -> `&&` in the end-of-section check would require
|
||||
/// ALL THREE prefixes to match simultaneously (impossible for a real
|
||||
/// single token), so the section would never end on `SF_` alone.
|
||||
#[test]
|
||||
fn assign_labels_fpl_section_ends_on_sf_boundary() {
|
||||
let mut flag = false;
|
||||
let tokens = strs(&[
|
||||
"FPL_MainFeature",
|
||||
"eng_MLP_",
|
||||
"SF_Something", // must end the FPL section
|
||||
"fra_AC3_", // must NOT be parsed
|
||||
]);
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
assert_eq!(labels.len(), 1, "only eng from FPL section");
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
}
|
||||
|
||||
/// Spec: the two per-type caps are independent — the loop only stops
|
||||
/// early once BOTH audio and subtitle counters have reached
|
||||
/// `MAX_STREAMS_PER_TYPE`. Reaching the audio cap alone must not cut
|
||||
/// off subtitle processing.
|
||||
/// Mutation: `&&` -> `||` in the outer stop-condition would break the
|
||||
/// loop as soon as EITHER counter reaches the cap, silently dropping
|
||||
/// a legitimate subtitle stream that comes after audio saturates.
|
||||
#[test]
|
||||
fn assign_labels_audio_cap_alone_does_not_stop_subtitle_processing() {
|
||||
let mut flag = false;
|
||||
let mut tokens = vec!["FPL_MainFeature".to_string()];
|
||||
for i in 1..=(MAX_STREAMS_PER_TYPE as usize) {
|
||||
tokens.push(format!("Audio Stream {}", i));
|
||||
}
|
||||
// Subtitle counter is still 0 here — well under the cap.
|
||||
tokens.push("eng_SDH_".to_string());
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
let subs: Vec<_> = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Subtitle)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
subs.len(),
|
||||
1,
|
||||
"a subtitle stream after the audio cap (but under the subtitle \
|
||||
cap) must still be labeled"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion to the above: with the subtitle counter saturated but
|
||||
/// audio still under its cap, a subsequent audio token must still be
|
||||
/// processed. Isolates the first `>=` operand (`audio_num >=
|
||||
/// MAX_STREAMS_PER_TYPE`) from the second.
|
||||
/// Mutation: `audio_num >= MAX_STREAMS_PER_TYPE` -> `audio_num <
|
||||
/// MAX_STREAMS_PER_TYPE` would flip the stop-condition to trigger
|
||||
/// whenever audio is UNDER cap and subtitle is AT/over cap — exactly
|
||||
/// this scenario — dropping the trailing audio token.
|
||||
#[test]
|
||||
fn assign_labels_subtitle_cap_alone_does_not_stop_audio_processing() {
|
||||
let mut flag = false;
|
||||
let mut tokens = vec!["FPL_MainFeature".to_string()];
|
||||
for _ in 1..=(MAX_STREAMS_PER_TYPE as usize) {
|
||||
tokens.push("eng_SDH_".to_string());
|
||||
}
|
||||
// Audio counter is still 0 here — well under the cap.
|
||||
tokens.push("fra_MLP_".to_string());
|
||||
let labels = assign_labels(&tokens, &mut flag);
|
||||
let audio: Vec<_> = labels
|
||||
.iter()
|
||||
.filter(|l| l.stream_type == StreamLabelType::Audio)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
audio.len(),
|
||||
1,
|
||||
"an audio stream after the subtitle cap (but under the audio \
|
||||
cap) must still be labeled"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec: subtitle placeholders (PG Stream N) do NOT advance the subtitle counter.
|
||||
/// Only audio placeholders (`Audio Stream N`) do.
|
||||
/// Mutation: also advance sub counter on PG placeholder → subtitle labels misnumbered.
|
||||
|
||||
@@ -685,4 +685,108 @@ mod tests {
|
||||
fn codec_empty_passes_through() {
|
||||
assert_eq!(codec(""), "");
|
||||
}
|
||||
|
||||
/// `purpose()`'s multi-word-compound fast path ORs two independent
|
||||
/// phrase checks ("audio description" / "descriptive service"). Each
|
||||
/// phrase, when it appears as a *word*-bounded match, is independently
|
||||
/// caught by the has_word fallback further down — so the OR only
|
||||
/// matters when a phrase appears as a *substring inside a larger word*
|
||||
/// (no boundary), which .contains() still catches but has_word() would
|
||||
/// reject.
|
||||
///
|
||||
/// Mutation: replace `||` with `&&` at line 238 → since "audio
|
||||
/// description" is absent here, the AND fails, the fast path doesn't
|
||||
/// fire, and the fallback has_word("descriptive") also fails (no word
|
||||
/// boundary before "descriptive" in "nondescriptive"), so purpose()
|
||||
/// wrongly returns Normal instead of Descriptive.
|
||||
#[test]
|
||||
fn purpose_descriptive_service_substring_without_word_boundary() {
|
||||
assert_eq!(
|
||||
purpose("nondescriptive service track"),
|
||||
LabelPurpose::Descriptive
|
||||
);
|
||||
}
|
||||
|
||||
/// `menu_lang()` maps every authoring-filename token in its table
|
||||
/// (ISO-639-2/B and /T spellings, plus ISO-639-1) to the canonical
|
||||
/// /T code used by the rest of the pipeline. Exhaustive per-arm check:
|
||||
/// deleting any single match arm makes that arm's tokens return None
|
||||
/// instead of the documented code.
|
||||
#[test]
|
||||
fn menu_lang_covers_every_table_entry() {
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("eng", "eng"),
|
||||
("en", "eng"),
|
||||
("ger", "deu"),
|
||||
("deu", "deu"),
|
||||
("de", "deu"),
|
||||
("fre", "fra"),
|
||||
("fra", "fra"),
|
||||
("fr", "fra"),
|
||||
("spa", "spa"),
|
||||
("es", "spa"),
|
||||
("ita", "ita"),
|
||||
("it", "ita"),
|
||||
("por", "por"),
|
||||
("pt", "por"),
|
||||
("jpn", "jpn"),
|
||||
("jap", "jpn"),
|
||||
("ja", "jpn"),
|
||||
("kor", "kor"),
|
||||
("ko", "kor"),
|
||||
("chi", "zho"),
|
||||
("zho", "zho"),
|
||||
("zh", "zho"),
|
||||
("rus", "rus"),
|
||||
("ru", "rus"),
|
||||
("dut", "nld"),
|
||||
("nld", "nld"),
|
||||
("nl", "nld"),
|
||||
("pol", "pol"),
|
||||
("pl", "pol"),
|
||||
("cze", "ces"),
|
||||
("ces", "ces"),
|
||||
("cs", "ces"),
|
||||
("dan", "dan"),
|
||||
("da", "dan"),
|
||||
("fin", "fin"),
|
||||
("fi", "fin"),
|
||||
("nor", "nor"),
|
||||
("no", "nor"),
|
||||
("swe", "swe"),
|
||||
("sv", "swe"),
|
||||
("hun", "hun"),
|
||||
("hu", "hun"),
|
||||
("gre", "ell"),
|
||||
("ell", "ell"),
|
||||
("el", "ell"),
|
||||
("tur", "tur"),
|
||||
("tr", "tur"),
|
||||
("ara", "ara"),
|
||||
("ar", "ara"),
|
||||
("hin", "hin"),
|
||||
("hi", "hin"),
|
||||
("tha", "tha"),
|
||||
("th", "tha"),
|
||||
("ukr", "ukr"),
|
||||
("uk", "ukr"),
|
||||
("cat", "cat"),
|
||||
("ca", "cat"),
|
||||
];
|
||||
for (token, expected) in cases {
|
||||
assert_eq!(
|
||||
menu_lang(token),
|
||||
Some(*expected),
|
||||
"menu_lang({:?}) should map to {:?}",
|
||||
token,
|
||||
expected
|
||||
);
|
||||
}
|
||||
// Case-insensitive and trimmed.
|
||||
assert_eq!(menu_lang("ENG"), Some("eng"));
|
||||
assert_eq!(menu_lang(" Eng "), Some("eng"));
|
||||
// Unrecognized token -> None, never a guess.
|
||||
assert_eq!(menu_lang("xyz"), None);
|
||||
assert_eq!(menu_lang(""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,4 +634,136 @@ mod tests {
|
||||
let (s, e) = find_element(xml, "name", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], "<di:name>Title</di:name>");
|
||||
}
|
||||
|
||||
// ── Malformed / truncated input (untrusted on-disc XML) ────────────────
|
||||
//
|
||||
// These scrapers run on XML lifted out of BD-J jar entries, which is
|
||||
// attacker-controllable. Every scan in this module must terminate and
|
||||
// stay in bounds on truncated or unbalanced input rather than panic.
|
||||
// XML 1.0 §2.3 defines the Name production these boundary rules model.
|
||||
|
||||
/// A quoted attribute value that is never closed must terminate the
|
||||
/// scan at EOF rather than reading past the end of the buffer.
|
||||
#[test]
|
||||
fn attr_unterminated_quoted_value_scan_stops_at_eof() {
|
||||
// The scanner enters the `y="` value and runs off the end looking
|
||||
// for the closing quote; `name` is never found.
|
||||
assert_eq!(attr(r#"<x y="oops"#, "name"), None);
|
||||
assert_eq!(attr("<x y='oops", "name"), None);
|
||||
// The truncated attribute itself has no terminated value either.
|
||||
assert_eq!(attr(r#"<x y="oops"#, "y"), None);
|
||||
}
|
||||
|
||||
/// An attribute name at EOF followed only by whitespace (no `=`) must
|
||||
/// return None, not read past the buffer while skipping that whitespace.
|
||||
#[test]
|
||||
fn attr_name_with_trailing_whitespace_and_no_equals_returns_none() {
|
||||
assert_eq!(attr("<x name ", "name"), None);
|
||||
}
|
||||
|
||||
/// `name=` followed only by whitespace to EOF has no value to return.
|
||||
#[test]
|
||||
fn attr_equals_with_trailing_whitespace_and_no_value_returns_none() {
|
||||
assert_eq!(attr("<x name= ", "name"), None);
|
||||
}
|
||||
|
||||
/// A quoted attribute value is opaque: a `name="..."` pair that appears
|
||||
/// *inside* another attribute's value must never be reported, even when
|
||||
/// it is preceded by whitespace so it would otherwise clear the
|
||||
/// word-boundary check.
|
||||
#[test]
|
||||
fn attr_decoy_name_after_space_inside_quoted_value_is_skipped() {
|
||||
assert_eq!(attr(r#"<x y=" name='decoy'" />"#, "name"), None);
|
||||
// The real attribute after the decoy still resolves.
|
||||
assert_eq!(
|
||||
attr(r#"<x y=" name='decoy'" name="real" />"#, "name"),
|
||||
Some("real".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// XML 1.0 §2.3 NameChar includes `-`, `_` and `.`, so `q-a`, `q_a` and
|
||||
/// `q.a` are each a single attribute name distinct from `a`. Searching
|
||||
/// for `a` must not match the tail of any of them.
|
||||
#[test]
|
||||
fn attr_name_char_boundary_covers_hyphen_underscore_and_dot() {
|
||||
assert_eq!(
|
||||
attr(r#"<x q-a="decoy" a="real" />"#, "a"),
|
||||
Some("real".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<x q_a="decoy" a="real" />"#, "a"),
|
||||
Some("real".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<x q.a="decoy" a="real" />"#, "a"),
|
||||
Some("real".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// An open tag truncated mid-attribute never terminates, so no element
|
||||
/// can be returned — and the attribute walk must not read past EOF.
|
||||
#[test]
|
||||
fn find_element_unterminated_open_tag_returns_none() {
|
||||
assert_eq!(find_element("<x attr=", "x", 0), None);
|
||||
}
|
||||
|
||||
/// A `/` as the final byte of the buffer is not a self-closing marker;
|
||||
/// probing for the `>` that would follow it must stay in bounds.
|
||||
#[test]
|
||||
fn find_element_trailing_slash_at_eof_returns_none() {
|
||||
assert_eq!(find_element("<a /", "a", 0), None);
|
||||
}
|
||||
|
||||
/// `/>` inside a quoted attribute value does not close the element.
|
||||
#[test]
|
||||
fn find_element_quoted_self_close_marker_does_not_end_element() {
|
||||
let xml = r#"<x a="/>"/>"#;
|
||||
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<x a="/>"/>"#);
|
||||
}
|
||||
|
||||
/// An attribute value whose quote is never closed leaves the open tag
|
||||
/// unterminated; the scan must end at EOF and report no element.
|
||||
#[test]
|
||||
fn find_element_unterminated_quoted_attr_returns_none() {
|
||||
assert_eq!(find_element(r#"<x a="oops"#, "x", 0), None);
|
||||
}
|
||||
|
||||
/// A `/` in the middle of an unquoted attribute value is not a
|
||||
/// self-closing marker — only `/>` is.
|
||||
#[test]
|
||||
fn find_element_unquoted_slash_is_not_self_closing() {
|
||||
let xml = "<a href=x/y>body</a>";
|
||||
let (s, e) = find_element(xml, "a", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], "<a href=x/y>body</a>");
|
||||
}
|
||||
|
||||
/// `text` must locate the real end of the open tag: a bare `/` inside
|
||||
/// an unquoted attribute value must not be treated as `/>`, which would
|
||||
/// shift the body start and leak tag bytes into the returned text.
|
||||
#[test]
|
||||
fn text_unquoted_slash_in_attr_does_not_truncate_body() {
|
||||
assert_eq!(text("<x a=b/c>hello</x>", "x"), Some("hello".into()));
|
||||
}
|
||||
|
||||
/// A `>` inside a quoted attribute value must not be mistaken for the
|
||||
/// end of the open tag when `text` computes the body start.
|
||||
#[test]
|
||||
fn text_quoted_gt_in_attr_does_not_truncate_body() {
|
||||
assert_eq!(text(r#"<x a="b>c">hello</x>"#, "x"), Some("hello".into()));
|
||||
}
|
||||
|
||||
/// A close tag truncated mid-name (`</x` with no `>`) is not a close
|
||||
/// tag; matching it must stay in bounds and report no text.
|
||||
#[test]
|
||||
fn text_truncated_close_tag_returns_none() {
|
||||
assert_eq!(text("<x>body</x", "x"), None);
|
||||
}
|
||||
|
||||
/// A `/` in element content is only a close tag when preceded by `<`.
|
||||
/// Body text containing `a/x>` must not be mistaken for `</x>`.
|
||||
#[test]
|
||||
fn text_slash_in_body_is_not_a_close_tag() {
|
||||
assert_eq!(text("<x>a/x> </x>", "x"), Some("a/x>".into()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user