mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
labels/deluxe: fetch the binding class by name, escape disc text, test confidence and union bounds
This commit is contained in:
+193
-22
@@ -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,14 +3200,15 @@ mod tests {
|
||||
CpInfo::Utf8("com/bydeluxe/Bind".into()),
|
||||
CpInfo::Class { name_index: 16 },
|
||||
];
|
||||
let bind_class = |extra: &[u8], count: usize| {
|
||||
let mut code = Vec::new();
|
||||
for _ in 0..4 {
|
||||
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);
|
||||
let bind = encode_class(
|
||||
encode_class(
|
||||
&cp,
|
||||
17,
|
||||
&[MethodSpec {
|
||||
@@ -3172,11 +3218,20 @@ mod tests {
|
||||
max_stack: 4,
|
||||
code,
|
||||
}],
|
||||
);
|
||||
let jar = build_zip(&[
|
||||
("com/bydeluxe/Lang.class", lang),
|
||||
("com/bydeluxe/Bind.class", bind),
|
||||
]);
|
||||
)
|
||||
};
|
||||
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),
|
||||
));
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user