diff --git a/Cargo.lock b/Cargo.lock index 22e3e80..a1ac432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,6 +356,7 @@ dependencies = [ "noxis-core", "noxis-runtime", "noxis-structure-check", + "serde_json", "thiserror", "tracing", ] diff --git a/crates/noxis-analysis/src/keep/dispatch.rs b/crates/noxis-analysis/src/keep/dispatch.rs new file mode 100644 index 0000000..22e3c57 --- /dev/null +++ b/crates/noxis-analysis/src/keep/dispatch.rs @@ -0,0 +1,86 @@ +//! Внешний диспатч: семейства методов, которые программный код зовёт через +//! внешние интерфейсы/классы (не из артефакта). Их нельзя переименовывать - +//! внешний вызывающий код не переписать, и JVM получит `AbstractMethodError`. + +use std::collections::{BTreeSet, HashSet}; + +use noxis_classfile::{Insn, InvokeKind, Program}; + +use crate::hierarchy::ClassHierarchy; + +/// Семейства `(name, params)`, которые нельзя переименовывать: программа +/// вызывает их виртуально/интерфейсно на внешнем владельце, а некоторый +/// класс в предках этого владельца (внутри артефакта) объявляет то же +/// `(name, params)`. Переименование разорвало бы диспатч через внешний +/// интерфейс/класс - JVM упала бы с `AbstractMethodError ... is abstract` +/// (реальный случай: шадоуed gson `LinkedTreeMap$EntrySet$1`, который +/// JDK `Map.forEach` зовёт через `invokeinterface Iterator.hasNext`). +pub fn external_dispatch_families( + program: &Program, + hierarchy: &ClassHierarchy, +) -> BTreeSet<(String, String)> { + let in_program: HashSet<&str> = program + .classes + .iter() + .map(|c| c.this_class.as_str()) + .collect(); + + // Виртуальные/интерфейсные вызовы на внешнем владельце (дедуплицированно). + let mut external_calls: BTreeSet<(String, String, String)> = BTreeSet::new(); + for class in &program.classes { + for method in &class.methods { + let Some(code) = &method.code else { continue }; + for insn in &code.insns { + if let Insn::Invoke { + kind, + owner, + name, + descriptor, + .. + } = insn + && matches!(kind, InvokeKind::Virtual | InvokeKind::Interface) + && !in_program.contains(owner.as_str()) + && let Some(params) = params_part(descriptor) + { + external_calls.insert((owner.clone(), name.clone(), params.to_string())); + } + } + } + } + if external_calls.is_empty() { + return BTreeSet::new(); + } + + // Для каждого класса - внешние предки, достижимые сквозь внутренние + // суперклассы/интерфейсы (цепочка обрывается на внешнем узле). + let mut locked: BTreeSet<(String, String)> = BTreeSet::new(); + for class in &program.classes { + let mut externals: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); + let mut stack = hierarchy.parents(&class.this_class); + while let Some(name) = stack.pop() { + if in_program.contains(name.as_str()) { + if seen.insert(name.clone()) { + stack.extend(hierarchy.parents(&name)); + } + } else { + externals.insert(name); + } + } + for (owner, name, params) in &external_calls { + if externals.contains(owner) { + locked.insert((name.clone(), params.clone())); + } + } + } + locked +} + +/// `()` → `()`, или `None` при кривом дескрипторе. +fn params_part(descriptor: &str) -> Option<&str> { + let close = descriptor.find(')')?; + if !descriptor.starts_with('(') { + return None; + } + Some(&descriptor[..=close]) +} diff --git a/crates/noxis-analysis/src/keep/mod.rs b/crates/noxis-analysis/src/keep/mod.rs index 1247f3c..7cea2bf 100644 --- a/crates/noxis-analysis/src/keep/mod.rs +++ b/crates/noxis-analysis/src/keep/mod.rs @@ -8,8 +8,11 @@ use regex::Regex; use crate::entrypoints::Entrypoints; use noxis_core::Config; +mod dispatch; mod scans; +pub use dispatch::external_dispatch_families; + /// Compiled glob: `*` - anything but `/`, `**` - anything, `?` - one char. fn compile(pattern: &str) -> Result { let mut out = String::from("^"); @@ -165,7 +168,6 @@ impl Analysis { keep.absorb(&entrypoints); keep.absorb_bootstrap(program); keep.absorb_databind(program); - keep.absorb_mixins(program); let locked_classes = program .classes diff --git a/crates/noxis-analysis/src/keep/scans.rs b/crates/noxis-analysis/src/keep/scans.rs index 89a9184..83132de 100644 --- a/crates/noxis-analysis/src/keep/scans.rs +++ b/crates/noxis-analysis/src/keep/scans.rs @@ -38,6 +38,11 @@ impl KeepRules { /// renaming entirely), which the `@Accessor`/`@Invoker`/`@Overwrite` /// lookups need. Mixins configs are found two ways: any resource ending /// in `mixins.json`, plus whatever `fabric.mod.json` lists. + /// + /// DISABLED: the resources pass now patches mixins.json properly + /// (rewrites package + entries via common prefix), so mixin classes + /// can be renamed like any other class. Reference: OBF fabric-obfuscator. + #[allow(dead_code)] pub fn absorb_mixins(&mut self, program: &Program) { let mut listed: Vec = Vec::new(); for resource in &program.resources { diff --git a/crates/noxis-analysis/src/lib.rs b/crates/noxis-analysis/src/lib.rs index 3ba8b8c..21d9318 100644 --- a/crates/noxis-analysis/src/lib.rs +++ b/crates/noxis-analysis/src/lib.rs @@ -16,4 +16,4 @@ pub mod keep; pub use entrypoints::Entrypoints; pub use hierarchy::{ClassHierarchy, RenameSafety, method_rename_safety}; pub use jdk::JdkHierarchy; -pub use keep::{Analysis, KeepRules, RuleError}; +pub use keep::{Analysis, KeepRules, RuleError, external_dispatch_families}; diff --git a/crates/noxis-analysis/tests/mixins.rs b/crates/noxis-analysis/tests/mixins.rs index f4718e8..14009b5 100644 --- a/crates/noxis-analysis/tests/mixins.rs +++ b/crates/noxis-analysis/tests/mixins.rs @@ -1,9 +1,7 @@ -//! Mixin keep: classes listed in `*.mixins.json` (and the plugin) stay. +//! Mixin keep: classes listed in `*.mixins.json` are now renameable. //! -//! The Mixin framework resolves mixin classes and their members by name at -//! runtime, so renaming them breaks the mod at startup. Entries use simple -//! names relative to the `package` field, which generic text patching can -//! never see - locking is the only sound answer. +//! The resources pass patches mixins.json (rewrites package + entries), +//! so mixin classes can be renamed. Reference: OBF fabric-obfuscator. #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -56,20 +54,20 @@ fn sample() -> Program { } #[test] -fn mixin_classes_and_plugin_stay() { +fn mixin_classes_are_renameable() { let program = sample(); let analysis = Analysis::run(&program, &Config::default()).expect("analysis"); - for kept in [ + // Mixin classes are no longer locked - resources pass patches the config. + for renameable in [ "demo/mixin/TargetMixin", "demo/mixin/other/Helper", "demo/mixin/ClientMixin", "demo/mixin/Plugin", ] { - assert!(analysis.is_locked(kept), "{kept} must survive renaming"); + assert!( + !analysis.is_locked(renameable), + "{renameable} should be renameable now" + ); } - assert!( - !analysis.is_locked("demo/App"), - "ordinary classes must still rename" - ); } diff --git a/crates/noxis-classfile/src/format/write/code/mod.rs b/crates/noxis-classfile/src/format/write/code/mod.rs index bf7b849..fa9b671 100644 --- a/crates/noxis-classfile/src/format/write/code/mod.rs +++ b/crates/noxis-classfile/src/format/write/code/mod.rs @@ -23,7 +23,14 @@ use crate::model::{Attribute, Code, ExceptionHandler}; use frames::convert_frames; /// Method body → a ready `Code` attribute (with logical instruction numbers). -pub(super) fn convert_code(code: &Code, pool: &mut Pool) -> Result { +/// +/// `emit_frames` is false for class-file versions below 50 (Java 6): those use +/// the type-inference verifier and must not carry a `StackMapTable`. +pub(super) fn convert_code( + code: &Code, + pool: &mut Pool, + emit_frames: bool, +) -> Result { let (labels, bare) = split_marks(&code.insns)?; let resolve = |label: Label| -> Result { labels @@ -55,7 +62,11 @@ pub(super) fn convert_code(code: &Code, pool: &mut Pool) -> Result Result attrs::convert(other, pool)?, }); } - if !frames.is_empty() { + if emit_frames && !frames.is_empty() { attributes.push(RtAttr::StackMapTable { name_index: pool.utf8("StackMapTable")?, frames, diff --git a/crates/noxis-classfile/src/format/write/mod.rs b/crates/noxis-classfile/src/format/write/mod.rs index a6cdf22..8419aec 100644 --- a/crates/noxis-classfile/src/format/write/mod.rs +++ b/crates/noxis-classfile/src/format/write/mod.rs @@ -22,6 +22,11 @@ pub(crate) fn write_class(class: &ClassFile) -> Result, ClassfileError> let mut pool = pool::Pool::new(); let version = RtVersion::from(class.version.major, class.version.minor) .map_err(|e| ClassfileError::Write(e.to_string()))?; + // `StackMapTable` is only legal from class-file version 50 (Java 6). Older + // classes use the type-inference verifier; emitting the attribute makes + // ASM/Paper reject the file ("Class versions V1_5 or less must use + // F_NEW frames"). Real case: the string-decoder is built at major 49. + let emit_frames = class.version.major >= 50; let this_class = pool.class(&class.this_class)?; let super_class = match &class.super_class { None => 0, @@ -37,7 +42,7 @@ pub(crate) fn write_class(class: &ClassFile) -> Result, ClassfileError> } let mut methods = Vec::with_capacity(class.methods.len()); for method in &class.methods { - methods.push(convert_method(method, &mut pool)?); + methods.push(convert_method(method, &mut pool, emit_frames)?); } let mut attributes = Vec::with_capacity(class.attributes.len()); for attr in &class.attributes { @@ -78,13 +83,17 @@ fn convert_field(field: &Field, pool: &mut pool::Pool) -> Result Result { +fn convert_method( + method: &Method, + pool: &mut pool::Pool, + emit_frames: bool, +) -> Result { let mut attributes = Vec::with_capacity(method.attributes.len() + 1); for attr in &method.attributes { attributes.push(attrs::convert(attr, pool)?); } if let Some(code) = &method.code { - attributes.push(code::convert_code(code, pool)?); + attributes.push(code::convert_code(code, pool, emit_frames)?); } Ok(RtMethod { access_flags: MethodAccessFlags::from_bits_truncate(method.access_flags), diff --git a/crates/noxis-classfile/tests/frames.rs b/crates/noxis-classfile/tests/frames.rs index 5920f08..da4cae1 100644 --- a/crates/noxis-classfile/tests/frames.rs +++ b/crates/noxis-classfile/tests/frames.rs @@ -178,6 +178,58 @@ fn java_available() -> bool { .is_ok_and(|out| out.status.success()) } +/// A v1.5 (major 49) class must not carry a `StackMapTable`: the old verifier +/// uses type inference, and ASM/Paper reject the attribute on pre-v6 classes +/// ("Class versions V1_5 or less must use F_NEW frames"). Real case: the +/// string-decoder is built at major 49; a later pass recomputes its frames, +/// and the writer used to emit them anyway - Paper then failed to convert the +/// plugin class. +#[test] +fn old_version_class_omits_stack_map_table() { + let insns = vec![ + Insn::Load { + slot: 0, + kind: SlotKind::Int, + }, + Insn::Branch(BranchCond::Zero(Cond::Ne), 100), + Insn::Const(Const::Int(0)), + Insn::Return(Some(Prim::Int)), + Insn::Mark(100), + Insn::Const(Const::Int(1)), + Insn::Return(Some(Prim::Int)), + ]; + let code = Code { + max_stack: 1, + max_locals: 1, + insns, + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let class = ClassBuilder::new("demo/Old", Some("java/lang/Object")) + .version(Version { + minor: 0, + major: 49, + }) + .method_with_code(0x0009, "pick", "(I)I", code) + .build(); + let hierarchy = MapHierarchy::default(); + let frames = compute_frames(&class.this_class, &class.methods[0], &hierarchy).expect("frames"); + assert!( + !frames.is_empty(), + "the branch must produce at least one frame" + ); + let mut class = class; + class.methods[0].code.as_mut().expect("code").frames = frames; + let bytes = format::write(&class).expect("write v1.5 class"); + assert!( + !bytes + .windows(b"StackMapTable".len()) + .any(|w| w == b"StackMapTable"), + "a major-49 class must not emit StackMapTable" + ); +} + /// Write a class into a package tree and run `java -Xverify:all`. /// Success means loading without verification errors (`main` may be missing). fn java_verify( diff --git a/crates/noxis-passes/Cargo.toml b/crates/noxis-passes/Cargo.toml index 0cd6a08..f8ef862 100644 --- a/crates/noxis-passes/Cargo.toml +++ b/crates/noxis-passes/Cargo.toml @@ -11,6 +11,7 @@ noxis-analysis = { workspace = true } noxis-classfile = { workspace = true } noxis-core = { workspace = true } noxis-runtime = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/noxis-passes/src/rename/assign.rs b/crates/noxis-passes/src/rename/assign.rs index 4ac8086..f9c716b 100644 --- a/crates/noxis-passes/src/rename/assign.rs +++ b/crates/noxis-passes/src/rename/assign.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use noxis_analysis::{Analysis, ClassHierarchy}; +use noxis_analysis::{Analysis, ClassHierarchy, external_dispatch_families}; use noxis_classfile::Program; use noxis_core::{Config, Context, NameStyle, Rng}; @@ -97,6 +97,7 @@ impl RenameMaps { pub(super) fn build_virtual_families( program: &Program, analysis: &Analysis, + hierarchy: &ClassHierarchy, ) -> (BTreeMap>, BTreeSet) { let mut families: BTreeMap> = BTreeMap::new(); let mut locked: BTreeSet = BTreeSet::new(); @@ -124,6 +125,11 @@ pub(super) fn build_virtual_families( )); } } + + // Семейства, которые диспатчатся через внешние интерфейсы/классы, обязаны + // сохранить имя: внешний вызывающий код не переименовать. + locked.extend(external_dispatch_families(program, hierarchy)); + (families, locked) } diff --git a/crates/noxis-passes/src/rename/mod.rs b/crates/noxis-passes/src/rename/mod.rs index c137694..9215b9e 100644 --- a/crates/noxis-passes/src/rename/mod.rs +++ b/crates/noxis-passes/src/rename/mod.rs @@ -64,7 +64,7 @@ impl Pass for RenamePass { // rename splits the override chain (the JVM then fails dispatch // with AbstractMethodError, real case: shaded gson's package-private // `ReflectionHelper$RecordHelper.isRecord` chain). - let (families, locked_families) = build_virtual_families(program, &analysis); + let (families, locked_families) = build_virtual_families(program, &analysis, &hierarchy); // 1. Class names. { diff --git a/crates/noxis-passes/src/resources/mixins.rs b/crates/noxis-passes/src/resources/mixins.rs new file mode 100644 index 0000000..5a30296 --- /dev/null +++ b/crates/noxis-passes/src/resources/mixins.rs @@ -0,0 +1,153 @@ +//! Fabric mixins.json and refmap: rewrite package + all references. +//! +//! Mixins reference classes by short names relative to `package`. +//! Plain full-name text replacement does not work. Algorithm: +//! 1. Collect full paths of all mixin entries (package + short name). +//! 2. Find common prefix of mapped names. +//! 3. Rewrite `package` to that prefix, entries relative to it. + +use std::collections::BTreeMap; + +/// Patch mixins.json: package, entries in mixins/client/server, plugin. +pub fn patch_mixins_json(text: &str, map: &BTreeMap) -> Option { + let mut root: serde_json::Value = serde_json::from_str(text).ok()?; + + let pkg_dot = root.get("package").and_then(|v| v.as_str()).unwrap_or(""); + let pkg_path = pkg_dot.replace('.', "/"); + + let sections = ["mixins", "client", "server"]; + + // 1. Collect full paths of all entries. + let mut full_names: Vec = Vec::new(); + for section in sections { + let Some(arr) = root.get(section).and_then(|v| v.as_array()) else { + continue; + }; + for el in arr { + let Some(short) = el.as_str() else { continue }; + let full = if pkg_path.is_empty() { + short.replace('.', "/") + } else { + format!("{}/{}", pkg_path, short.replace('.', "/")) + }; + let mapped = map.get(&full).cloned().unwrap_or(full); + full_names.push(mapped); + } + } + + // 2. Common prefix of mapped names. + let common = common_prefix(&full_names); + let common_dot = common.replace('/', "."); + + // 3. Rewrite package and entries. + for section in sections { + let Some(arr) = root.get(section).and_then(|v| v.as_array()) else { + continue; + }; + let entries: Vec = arr + .iter() + .filter_map(|el| el.as_str()) + .map(|short| { + let full = if pkg_path.is_empty() { + short.replace('.', "/") + } else { + format!("{}/{}", pkg_path, short.replace('.', "/")) + }; + let mapped = map.get(&full).cloned().unwrap_or(full); + let tail = if !common.is_empty() && mapped.starts_with(&format!("{common}/")) { + &mapped[common.len() + 1..] + } else { + &mapped + }; + tail.replace('/', ".") + }) + .collect(); + root[section] = + serde_json::Value::Array(entries.into_iter().map(serde_json::Value::String).collect()); + } + root["package"] = serde_json::Value::String(common_dot); + + // 4. Plugin (full class name). + if let Some(plugin) = root.get("plugin").and_then(|v| v.as_str()) { + let cls = plugin.replace('.', "/"); + if let Some(mapped) = map.get(&cls) { + root["plugin"] = serde_json::Value::String(mapped.replace('/', ".")); + } + } + + Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string())) +} + +/// Patch refmap.json: recursively replace all string values. +pub fn patch_refmap_json(text: &str, map: &BTreeMap) -> Option { + let mut root: serde_json::Value = serde_json::from_str(text).ok()?; + remap_json_value(&mut root, map); + Some(serde_json::to_string_pretty(&root).unwrap_or_else(|_| text.to_string())) +} + +/// Recursively replace strings in JSON via mapping. +fn remap_json_value(value: &mut serde_json::Value, map: &BTreeMap) { + match value { + serde_json::Value::Object(obj) => { + let remapped: serde_json::Map = obj + .iter() + .map(|(key, val)| { + let new_key = remap_reference(key, map); + let mut new_val = val.clone(); + remap_json_value(&mut new_val, map); + (new_key, new_val) + }) + .collect(); + *obj = remapped; + } + serde_json::Value::Array(arr) => { + for el in arr.iter_mut() { + remap_json_value(el, map); + } + } + serde_json::Value::String(s) => { + *s = remap_reference(s, map); + } + _ => {} + } +} + +/// Replace class/method/field reference in a string. +fn remap_reference(s: &str, map: &BTreeMap) -> String { + // Internal name (pkg/Class) - return internal form. + if let Some(mapped) = map.get(s) { + return mapped.clone(); + } + // Dotted name (pkg.Class) - return dotted form. + let internal = s.replace('.', "/"); + if internal != s + && let Some(mapped) = map.get(&internal) + { + return mapped.replace('/', "."); + } + s.to_string() +} + +/// Find common path prefix of names (separator `/`). +fn common_prefix(names: &[String]) -> String { + if names.is_empty() { + return String::new(); + } + let parts: Vec> = names.iter().map(|s| s.split('/').collect()).collect(); + let first = &parts[0]; + if first.len() <= 1 { + return String::new(); + } + let mut common = first.len() - 1; + for cur in parts.iter().skip(1) { + let mut k = 0; + while k < common && k < cur.len() && first[k] == cur[k] { + k += 1; + } + common = k; + } + if common == 0 { + return String::new(); + } + first[..common].join("/") +} diff --git a/crates/noxis-passes/src/resources/mod.rs b/crates/noxis-passes/src/resources/mod.rs index 7cd97b4..14138f9 100644 --- a/crates/noxis-passes/src/resources/mod.rs +++ b/crates/noxis-passes/src/resources/mod.rs @@ -14,12 +14,16 @@ #[cfg(test)] mod tests; +mod mixins; + use std::cmp::Reverse; use std::collections::BTreeMap; use noxis_classfile::Program; use noxis_core::{Context, CoreError, Pass, PassId, PassReport}; +pub use mixins::{patch_mixins_json, patch_refmap_json}; + /// Расширения текстовых ресурсов, которые имеют смысл переписывать. const TEXT_EXTS: [&str; 9] = [ ".json", @@ -68,7 +72,14 @@ impl Pass for ResourcesPass { skipped += 1; continue; }; - let updated = remap_text(text, &map); + let lower = resource.path.to_ascii_lowercase(); + let updated = if lower.ends_with(".mixins.json") || lower.ends_with("mixins.json") { + patch_mixins_json(text, &map).unwrap_or_else(|| remap_text(text, &map)) + } else if lower.ends_with("refmap.json") { + patch_refmap_json(text, &map).unwrap_or_else(|| remap_text(text, &map)) + } else { + remap_text(text, &map) + }; if updated == text { skipped += 1; continue; diff --git a/crates/noxis-passes/src/resources/tests.rs b/crates/noxis-passes/src/resources/tests.rs index fbab3cf..11ea78e 100644 --- a/crates/noxis-passes/src/resources/tests.rs +++ b/crates/noxis-passes/src/resources/tests.rs @@ -5,7 +5,7 @@ use noxis_classfile::{Program, Resource}; use noxis_core::{Config, Context, Pass}; -use super::{ResourcesPass, is_text, remap_text}; +use super::{ResourcesPass, is_text, patch_mixins_json, patch_refmap_json, remap_text}; fn mapping() -> std::collections::BTreeMap { let mut map = std::collections::BTreeMap::new(); @@ -158,3 +158,78 @@ fn pass_is_a_noop_without_a_mapping() { "должна быть заметка о пустом маппинге" ); } + +fn mixin_mapping() -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + map.insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into()); + map.insert("dev/loki/mixins/MixinB".into(), "xYz/abc/MixinB".into()); + map.insert("dev/loki/mixins/Plugin".into(), "xYz/abc/Plugin".into()); + map +} + +#[test] +fn patch_mixins_json_rewrites_package_and_entries() { + let json = r#"{ + "package": "dev.loki.mixins", + "plugin": "dev.loki.mixins.Plugin", + "mixins": ["MixinA", "MixinB"] + }"#; + let patched = patch_mixins_json(json, &mixin_mapping()).expect("patch"); + assert!( + patched.contains("\"package\": \"xYz.abc\""), + "got: {patched}" + ); + assert!(patched.contains("\"MixinA\""), "got: {patched}"); + assert!(patched.contains("\"xYz.abc.Plugin\""), "got: {patched}"); +} + +#[test] +fn patch_mixins_json_handles_empty_package() { + let json = r#"{"mixins": ["a.b.MixinA"]}"#; + let mut map = std::collections::BTreeMap::new(); + map.insert("a/b/MixinA".into(), "x/M".into()); + let patched = patch_mixins_json(json, &map).expect("patch"); + assert!(patched.contains("\"M\""), "got: {patched}"); +} + +#[test] +fn patch_refmap_json_rewrites_strings() { + let json = r#"{ + "mappings": { + "dev/loki/mixins/MixinA": {"method_1234": "a/b/Target"} + }, + "data": {"dev/loki/mixins/MixinA": {"method_1234": "field_5678"}} + }"#; + let mut map = std::collections::BTreeMap::new(); + map.insert("a/b/Target".into(), "c/D".into()); + let patched = patch_refmap_json(json, &map).expect("patch"); + assert!(patched.contains("c/D"), "got: {patched}"); +} + +#[test] +fn pass_patches_mixins_json_via_specialized_patcher() { + let mut program = Program { + classes: Vec::new(), + resources: vec![Resource { + path: "lovisual.mixins.json".into(), + data: br#"{ + "package": "dev.loki.mixins", + "mixins": ["MixinA"] + }"# + .to_vec(), + }], + }; + + let mut ctx = Context::new(Config::default()); + ctx.mapping + .classes + .insert("dev/loki/mixins/MixinA".into(), "xYz/abc/MixinA".into()); + + let report = ResourcesPass + .apply(&mut ctx, &mut program) + .expect("resources"); + + assert_eq!(report.transformed, 1); + let text = String::from_utf8(program.resources[0].data.clone()).unwrap(); + assert!(text.contains("\"package\": \"xYz.abc\""), "got: {text}"); +} diff --git a/crates/noxis-passes/tests/family.rs b/crates/noxis-passes/tests/family.rs index a556004..791900c 100644 --- a/crates/noxis-passes/tests/family.rs +++ b/crates/noxis-passes/tests/family.rs @@ -103,3 +103,59 @@ fn package_private_overrides_stay_consistent_without_public_rename() { "families off must keep the chain untouched, got {names:?}" ); } + +/// `demo/Iter` implements the external `java/util/Iterator`; `demo/Use` calls +/// `invokeinterface Iterator.hasNext`. The `(hasNext, ()Z)` family must keep +/// its original name: renaming it splits dispatch through the JDK interface +/// (the JVM then fails with `AbstractMethodError ... is abstract`, real case: +/// shaded gson's `LinkedTreeMap$EntrySet$1` iterator reached from +/// `Map.forEach`). +fn external_iterator_sample() -> Program { + let iter = ClassBuilder::new("demo/Iter", Some("java/lang/Object")) + .version(Version::JAVA_21) + .interface("java/util/Iterator") + .method_with_code(0x0001, "hasNext", "()Z", check_code()) + .build(); + let use_code = Code { + max_stack: 1, + max_locals: 1, + insns: vec![ + Insn::AconstNull, + Insn::Invoke { + kind: noxis_classfile::InvokeKind::Interface, + owner: "java/util/Iterator".to_string(), + name: "hasNext".to_string(), + descriptor: "()Z".to_string(), + itf: true, + }, + Insn::Return(Some(Prim::Int)), + ], + exception_table: Vec::new(), + frames: Vec::new(), + attributes: Vec::new(), + }; + let use_class = ClassBuilder::new("demo/Use", Some("java/lang/Object")) + .version(Version::JAVA_21) + .method_with_code(0x0001, "use", "()Z", use_code) + .build(); + Program { + classes: vec![iter, use_class], + resources: Vec::new(), + } +} + +#[test] +fn externally_dispatched_interface_method_keeps_its_name() { + let mut program = external_iterator_sample(); + run(&mut program, true); + + let kept = program.classes.iter().any(|c| { + c.methods + .iter() + .any(|m| m.name == "hasNext" && m.descriptor == "()Z") + }); + assert!( + kept, + "hasNext implements java/util/Iterator and must not be renamed" + ); +} diff --git a/luac.out b/luac.out new file mode 100644 index 0000000..e69de29